JavaRush /Blog Java /Random-FR /Vous ne pouvez pas ruiner Java avec un fil : partie II - ...
Viacheslav
Niveau 3

Vous ne pouvez pas ruiner Java avec un fil : partie II - synchronisation

Publié dans le groupe Random-FR

Introduction

Ainsi, nous savons qu'il existe des threads en Java, que vous pouvez lire dans la revue « Vous ne pouvez pas gâcher Java avec un thread : Partie I - Threads ». Les threads sont nécessaires pour travailler simultanément. Par conséquent, il est très probable que les threads interagiront d’une manière ou d’une autre. Comprenons comment cela se produit et quels sont les contrôles de base dont nous disposons. Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 1

Rendement

La méthode Thread.yield() est mystérieuse et rarement utilisée. Il existe de nombreuses variantes de sa description sur Internet. Au point que certains écrivent sur une sorte de file d'attente de threads, dans laquelle le thread descendra en tenant compte de leurs priorités. Quelqu'un écrit que le thread changera son statut d'exécutable à exécutable (bien qu'il n'y ait pas de division entre ces statuts et que Java ne fasse pas de distinction entre eux). Mais en réalité, tout est bien plus inconnu et, en un sens, plus simple. Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 2Au sujet de la documentation des méthodes, yieldil y a un bug " JDK-6416721 : (spec thread) Fix Thread.yield() javadoc ". Si vous le lisez, il est clair qu'en fait, la méthode yieldne fait que transmettre une recommandation au planificateur de thread Java selon laquelle ce thread peut bénéficier de moins de temps d'exécution. Mais ce qui se passera réellement, si le planificateur entendra la recommandation et ce qu'il fera en général dépend de la mise en œuvre de la JVM et du système d'exploitation. Ou peut-être à cause d'autres facteurs. Toute cette confusion était probablement due à la refonte du multithreading lors du développement du langage Java. Vous pouvez en savoir plus dans la revue « Brève introduction à Java Thread.yield() ».

Sommeil - Fil de discussion pour s'endormir

Un thread peut s'endormir lors de son exécution. Il s'agit du type d'interaction le plus simple avec d'autres threads. Le système d'exploitation sur lequel est installée la machine virtuelle Java, sur laquelle le code Java est exécuté, possède son propre planificateur de threads, appelé Thread Scheduler. C'est lui qui décide quel thread exécuter et à quel moment. Le programmeur ne peut pas interagir avec ce planificateur directement à partir du code Java, mais il peut, via la JVM, demander au planificateur de mettre le thread en pause pendant un moment, pour le « mettre en veille ». Vous pouvez en savoir plus dans les articles " Thread.sleep() " et " Comment fonctionne le multithreading ". De plus, vous pouvez découvrir comment fonctionnent les threads dans le système d'exploitation Windows : " Internals of Windows Thread ". Maintenant, nous le verrons de nos propres yeux. Sauvons le code suivant dans un fichierHelloWorldApp.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();
    }
}
Comme vous pouvez le voir, nous avons une tâche qui attend 60 secondes, après quoi le programme se termine. Nous compilons javac HelloWorldApp.javaet exécutons java HelloWorldApp. Il est préférable de lancer dans une fenêtre séparée. Par exemple, sous Windows, cela ressemblerait à ceci : start java HelloWorldApp. A l'aide de la commande jps, nous connaissons le PID du processus et ouvrons la liste des threads en utilisant jvisualvm --openpid pidПроцесса: Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 3Comme vous pouvez le voir, notre thread est entré en statut Sleeping. En fait, la mise en veille du fil de discussion actuel peut être effectuée de manière plus élégante :
try {
	TimeUnit.SECONDS.sleep(60);
	System.out.println("Waked up");
} catch (InterruptedException e) {
	e.printStackTrace();
}
Vous avez probablement remarqué que nous traitons partout InterruptedException? Comprenons pourquoi.

Interrompre un fil de discussion ou Thread.interrupt

Le fait est que pendant que le thread attend en veille, quelqu'un peut vouloir interrompre cette attente. Dans ce cas, nous traitons une telle exception. Cela a été fait après que la méthode Thread.stopa été déclarée obsolète, c'est-à-dire obsolète et indésirable à utiliser. La raison en était que lorsque la méthode était appelée, stople thread était simplement « tué », ce qui était très imprévisible. Nous ne pouvions pas savoir quand le flux serait arrêté, nous ne pouvions pas garantir la cohérence des données. Imaginez que vous écrivez des données dans un fichier, puis que le flux est détruit. Par conséquent, ils ont décidé qu'il serait plus logique de ne pas tuer le flux, mais de l'informer qu'il devait être interrompu. La manière de réagir à cela dépend du flux lui-même. Plus de détails peuvent être trouvés dans le document « Pourquoi Thread.stop est-il obsolète ? » Regardons un exemple :
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();
}
Dans cet exemple, nous n'attendrons pas 60 secondes, mais imprimerons immédiatement « Interrompu ». C'est parce que nous avons appelé la méthode du thread interrupt. Cette méthode définit un "indicateur interne appelé état d'interruption". Autrement dit, chaque thread possède un indicateur interne qui n’est pas directement accessible. Mais nous disposons de méthodes natives pour interagir avec ce drapeau. Mais ce n’est pas la seule solution. Un thread peut être en cours d’exécution, n’attendant pas quelque chose, mais effectuant simplement des actions. Mais elle peut prévoir qu'ils voudront l'achever à un moment donné de son travail. Par exemple:
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();
}
Dans l'exemple ci-dessus, vous pouvez voir que la boucle whiles'exécutera jusqu'à ce que le thread soit interrompu en externe. La chose importante à savoir sur l' indicateur isInterrupted est que si nous l'attrapons InterruptedException, l'indicateur isInterruptedest réinitialisé, puis isInterruptedil retournera false. Il existe également une méthode statique dans la classe Thread qui s'applique uniquement au thread actuel - Thread.interrupted() , mais cette méthode réinitialise l'indicateur sur false ! Vous pouvez en savoir plus dans le chapitre « Interruption du fil de discussion ».

Rejoindre – En attente de la fin d'un autre fil de discussion

Le type d'attente le plus simple consiste à attendre qu'un autre thread se termine.
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");
}
Dans cet exemple, le nouveau thread va dormir pendant 5 secondes. Dans le même temps, le thread principal attendra que le thread endormi se réveille et termine son travail. Si vous parcourez JVisualVM, l'état du thread ressemblera à ceci : Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 4Grâce aux outils de surveillance, vous pouvez voir ce qui se passe avec le thread. La méthode joinest assez simple, car il s'agit simplement d'une méthode avec du code Java qui s'exécute waitpendant que le thread sur lequel elle est appelée est vivant. Une fois le thread mort (à la fin), l'attente prend fin. C'est la magie de la méthodejoin . Passons donc à la partie la plus intéressante.

Moniteur de concepts

En multithreading, il existe un système tel que Monitor. En général, le mot moniteur est traduit du latin par « surveillant » ou « surveillant ». Dans le cadre de cet article, nous essaierons de rappeler l'essentiel, et pour ceux qui le souhaitent, je vous demande de vous plonger dans la matière à partir des liens pour plus de détails. Commençons notre voyage avec la spécification du langage Java, c'est-à-dire avec JLS : " 17.1. Synchronisation ". Il dit ce qui suit : Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 5Il s'avère qu'à des fins de synchronisation entre les threads, Java utilise un certain mécanisme appelé « Moniteur ». Chaque objet est associé à un moniteur et les threads peuvent le verrouiller ou le déverrouiller. Ensuite, nous trouverons un tutoriel de formation sur le site d'Oracle : « Intrinsic Locks and Synchronization ». Ce didacticiel explique que la synchronisation en Java est construite autour d'une entité interne appelée verrou intrinsèque ou verrou de moniteur. Souvent, un tel verrou est simplement appelé « moniteur ». Nous voyons également à nouveau que chaque objet en Java est associé à un verrou intrinsèque. Vous pouvez lire " Java - Verrouillages intrinsèques et synchronisation ". Ensuite, il est important de comprendre comment un objet en Java peut être associé à un moniteur. Chaque objet en Java a un en-tête - une sorte de métadonnées internes qui ne sont pas disponibles pour le programmeur à partir du code, mais dont la machine virtuelle a besoin pour fonctionner correctement avec les objets. L'en-tête de l'objet inclut un MarkWord qui ressemble à ceci : Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 6

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

Un article de Habr est très utile ici : " Mais comment fonctionne le multithreading ? Partie I : synchronisation ." À cet article, il convient d'ajouter une description du résumé du bloc de tâches du bugtaker JDK : « JDK-8183909 ». Vous pouvez lire la même chose dans " JEP-8183909 ". Ainsi, en Java, un moniteur est associé à un objet et le thread peut bloquer ce thread, ou ils disent aussi « obtenir un verrou ». L'exemple le plus simple :
public class HelloWorld{
    public static void main(String []args){
        Object object = new Object();
        synchronized(object) {
            System.out.println("Hello World");
        }
    }
}
Ainsi, à l'aide du mot-clé, synchronizedle thread actuel (dans lequel ces lignes de code sont exécutées) tente d'utiliser le moniteur associé à l'objet objectet « d'obtenir un verrou » ou de « capturer le moniteur » (la deuxième option est même préférable). S'il n'y a pas de conflit pour le moniteur (c'est-à-dire que personne d'autre ne veut se synchroniser sur le même objet), Java peut essayer d'effectuer une optimisation appelée « verrouillage biaisé ». Le titre de l'objet dans Mark Word contiendra la balise correspondante et un enregistrement du fil auquel le moniteur est attaché. Cela réduit la surcharge lors de la capture du moniteur. Si le moniteur a déjà été lié à un autre thread auparavant, ce verrouillage n'est pas suffisant. La JVM passe au type de verrouillage suivant : le verrouillage de base. Il utilise des opérations de comparaison et d'échange (CAS). Dans le même temps, l'en-tête de Mark Word ne stocke plus Mark Word lui-même, mais un lien vers son stockage + la balise est modifié afin que la JVM comprenne que nous utilisons le verrouillage de base. S'il y a un conflit pour le moniteur entre plusieurs threads (l'un a capturé le moniteur et le second attend que le moniteur soit libéré), alors la balise dans Mark Word change et Mark Word commence à stocker une référence au moniteur comme un objet - une entité interne de la JVM. Comme indiqué dans le JEP, dans ce cas, de l'espace est requis dans la zone mémoire Native Heap pour stocker cette entité. Le lien vers l'emplacement de stockage de cette entité interne sera localisé dans l'objet Mark Word. Ainsi, comme on le voit, le moniteur est en réalité un mécanisme permettant d'assurer la synchronisation de l'accès de plusieurs threads aux ressources partagées. Il existe plusieurs implémentations de ce mécanisme entre lesquelles la JVM bascule. Par conséquent, par souci de simplicité, lorsque nous parlons d’un moniteur, nous parlons en réalité de verrous. Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 7

Synchronisé et en attente par serrure

Le concept de moniteur, comme nous l'avons vu précédemment, est étroitement lié au concept de « bloc de synchronisation » (ou, comme on l'appelle aussi, de section critique). Regardons un exemple :
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(" ...");
	}
}
Ici, le thread principal envoie d'abord la tâche à un nouveau thread, puis « capture » immédiatement le verrou et effectue une longue opération avec lui (8 secondes). Pendant tout ce temps, la tâche ne peut pas entrer dans le bloc pour son exécution synchronized, car la serrure est déjà occupée. Si un thread ne peut pas obtenir de verrou, il l'attendra sur le moniteur. Dès qu'il le recevra, il poursuivra l'exécution. Lorsqu'un thread quitte le moniteur, il libère le verrou. Dans JVisualVM, cela ressemblerait à ceci : Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 8Comme vous pouvez le voir, l'état dans JVisualVM est appelé "Moniteur" car le thread est bloqué et ne peut pas occuper le moniteur. Vous pouvez également connaître l'état du thread dans le code, mais le nom de cet état ne coïncide pas avec les termes JVisualVM, bien qu'ils soient similaires. Dans ce cas, th1.getState()la boucle forretournera BLOCKED , car Pendant que la boucle est en cours d'exécution, le moniteur lockest occupé mainpar le thread, et le thread th1est bloqué et ne peut pas continuer à fonctionner jusqu'à ce que le verrou soit renvoyé. En plus des blocs de synchronisation, une méthode entière peut être synchronisée. Par exemple, une méthode de la classe HashTable:
public synchronized int size() {
	return count;
}
Dans une unité de temps, cette méthode sera exécutée par un seul thread. Mais il nous faut une serrure, non ? Oui j'en ai besoin. Dans le cas des méthodes objets, le verrou sera this. Il y a une discussion intéressante sur ce sujet : " Y a-t-il un avantage à utiliser une méthode synchronisée au lieu d'un bloc synchronisé ? ". Si la méthode est statique, alors le verrou ne le sera pas this(puisque pour une méthode statique il ne peut pas y en avoir this), mais l'objet de classe (par exemple,Integer.class ).

Attendez et attendez sur le moniteur. Les méthodes notify et notifyAll

Thread a une autre méthode d'attente, qui est connectée au moniteur. Contrairement sleepà et join, il ne peut pas simplement être appelé. Et son nom est wait. La méthode est exécutée waitsur l'objet sur le moniteur duquel on veut attendre. Voyons un exemple :
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();
	    }
}
Dans JVisualVM, cela ressemblera à ceci : Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 10Pour comprendre comment cela fonctionne, vous devez vous rappeler que les méthodes waitfont référence notifyà java.lang.Object. Il semble étrange que les méthodes liées aux threads se trouvent dans le fichier Object. Mais c’est là que réside la réponse. Comme nous nous en souvenons, chaque objet en Java a un en-tête. L'en-tête contient diverses informations de service, notamment des informations sur le moniteur (des données sur l'état de verrouillage). Et comme nous nous en souvenons, chaque objet (c'est-à-dire chaque instance) a une association avec une entité JVM interne appelée verrou intrinsèque, également appelé moniteur. Dans l'exemple ci-dessus, la tâche décrit que nous entrons le bloc de synchronisation sur le moniteur associé à lock. S'il est possible d'obtenir un verrouillage sur ce moniteur, alors wait. Le thread exécutant cette tâche libérera le moniteur lock, mais rejoindra la file d'attente des threads en attente de notification sur le moniteur lock. Cette file d'attente de threads s'appelle WAIT-SET, ce qui reflète plus correctement l'essence. Il s'agit plus d'un décor que d'une file d'attente. Le thread maincrée un nouveau thread avec la tâche, le démarre et attend 3 secondes. Cela permet, avec un degré de probabilité élevé, à un nouveau thread de récupérer le verrou avant le thread mainet de se mettre en file d'attente sur le moniteur. Après quoi le thread mainlui-même entre dans le bloc de synchronisation locket effectue une notification du thread sur le moniteur. Une fois la notification envoyée, le thread mainlibère le moniteur locket le nouveau thread (qui attendait auparavant) lockcontinue de s'exécuter après avoir attendu la libération du moniteur. Il est possible d'envoyer une notification à un seul des threads ( notify) ou à tous les threads de la file d'attente à la fois ( notifyAll). Vous pouvez en savoir plus dans " Différence entre notify() et notifyAll() en Java ". Il est important de noter que l'ordre de notification dépend de l'implémentation de la JVM. Vous pouvez en savoir plus dans " Comment résoudre la famine avec notify et notifyall ? ". La synchronisation peut être effectuée sans spécifier d'objet. Cela peut être fait lorsque ce n'est pas une section distincte de code qui est synchronisée, mais une méthode entière. Par exemple, pour les méthodes statiques, le verrou sera l'objet de classe (obtenu via .class) :
public static synchronized void printA() {
	System.out.println("A");
}
public static void printB() {
	synchronized(HelloWorld.class) {
		System.out.println("B");
	}
}
En termes d’utilisation des verrous, les deux méthodes sont identiques. Si la méthode n'est pas statique, alors la synchronisation sera effectuée selon le courant instance, c'est-à-dire selon this. À propos, nous avons dit plus tôt qu'en utilisant cette méthode, getStatevous pouvez obtenir l'état d'un thread. Voici donc un thread qui est mis en file d'attente par le moniteur, le statut sera WAITING ou TIMED_WAITING si la méthode waita spécifié un délai d'attente. Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 11

Cycle de vie d'un thread

Comme nous l'avons vu, le flux change de statut au cours de la vie. Essentiellement, ces changements constituent le cycle de vie du thread. Lorsqu'un fil de discussion vient d'être créé, il a le statut NOUVEAU. Dans cette position, il n'a pas encore démarré et le Java Thread Scheduler ne sait encore rien du nouveau thread. Pour que le planificateur de threads connaisse un thread, vous devez appeler le thread.start(). Ensuite, le thread passera à l’état RUNNABLE. Il existe de nombreux schémas incorrects sur Internet dans lesquels les états Exécutable et En cours d'exécution sont séparés. Mais c'est une erreur, parce que... Java ne fait pas de différence entre les statuts « prêt à exécuter » et « en cours d'exécution ». Lorsqu'un thread est vivant mais non actif (non exécutable), il se trouve dans l'un des deux états suivants :
  • BLOQUÉ - attend l'entrée dans une section protégée, c'est-à-dire au synchonizedbloc.
  • WAITING - attend un autre thread en fonction d'une condition. Si la condition est vraie, le planificateur de thread démarre le thread.
Si un thread est en attente, il est dans l'état TIMED_WAITING. Si le thread n'est plus en cours d'exécution (terminé avec succès ou avec une exception), il passe à l'état TERMINATED. Pour connaître l'état d'un thread (son état), on utilise la méthode getState. Les threads ont également une méthode isAlivequi renvoie true si le thread n'est pas terminé.

LockSupport et stationnement de threads

Depuis Java 1.6, il existait un mécanisme intéressant appelé LockSupport . Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 12Cette classe associe un « permis » ou une autorisation à chaque thread qui l'utilise. L'appel de méthode parkretourne immédiatement si un permis est disponible, occupant ce même permis pendant l'appel. Sinon, il est bloqué. L’appel de la méthode unparkrend le permis disponible s’il n’est pas déjà disponible. Il n'y a qu'un seul permis. Dans l'API Java, LockSupportun certain Semaphore. Regardons un exemple simple :
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!");
    }
}
Ce code attendra indéfiniment car le sémaphore a désormais 0 permis. Et lorsqu'il est appelé dans le code acquire(c'est-à-dire demander l'autorisation), le thread attend jusqu'à ce qu'il reçoive l'autorisation. Puisque nous attendons, nous sommes obligés de le traiter InterruptedException. Fait intéressant, un sémaphore implémente un état de thread distinct. Si nous regardons dans JVisualVM, nous verrons que notre état n'est pas Wait, mais Park. Vous ne pouvez pas ruiner Java avec un thread : Partie II - synchronisation - 13Regardons un autre exemple :
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);
}
L'état du thread sera WAITING, mais JVisualVM fait la distinction waitentre from synchronizedet parkfrom LockSupport. Pourquoi celui-ci est-il si important LockSupport? Revenons à l'API Java et examinons Thread State WAITING . Comme vous pouvez le constater, il n’existe que trois façons d’y accéder. 2 façons - ceci waitet join. Et le troisième est LockSupport. Les verrous en Java sont construits sur les mêmes principes LockSupportet représentent des outils de niveau supérieur. Essayons d'en utiliser un. Regardons par exemple 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();
    }
}
Comme dans les exemples précédents, tout est simple ici. lockattend que quelqu'un libère une ressource. Si nous regardons dans JVisualVM, nous verrons que le nouveau thread sera garé jusqu'à ce que mainle thread lui donne le verrou. Vous pouvez en savoir plus sur les verrous ici : " Programmation multithread dans Java 8. Deuxième partie. Synchronisation de l'accès aux objets mutables " et " Java Lock API. Théorie et exemple d'utilisation ". Pour mieux comprendre l'implémentation des verrous, il est utile de lire sur Phazer dans la présentation " Phaser Class ". Et en parlant des différents synchroniseurs, vous devez lire l'article sur Habré « Java.util.concurrent.* Synchronizers Reference ».

Total

Dans cette revue, nous avons examiné les principales façons dont les threads interagissent en Java. Matériels supplémentaires: #Viacheslav
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION