États des threads et communication inter-threads
Les méthodes wait() et notify() d'un objet Object permettent d'implémenter un modèle d'attente et de notification. Il est crucial de distinguer l'état d'attente de l'état de blocage. Un thread en état d'attente peut être réveillé via notify() pour reprendre son exécution, tandis qu'un thread bloqué attend d'acquérir un verrou. L'appel à wait() place le thread courant en attente jusqu'à ce qu'un autre thread invoque notify() ou notifyAll(). La méthode notify() réveille un unique thread en attente.
L'utilisation de thread.join() permet d'intégrer un thread spécifié dans le thread courant, ce qui fusionne l'exécution de deux threads alternés en une exécution séquentielle. Par exemple, si le thread B appelle join() sur le thread A, B s'arrêtera jusqu'à ce que A ait terminé son exécution.
Les ressources d'un thread incluent principalement les ressources CPU et les ressources de verrouillage. La méthode sleep(long milliseconds) cède les ressources CPU mais ne libère pas les verrous, permettant à d'autres threads d'utiliser le temps CPU. Cependant, si un autre thread doit acquérir le verrou détenu par le thread dormant, il restera bloqué. En revanche, les threads qui ne rivalisent pas pour le même verrou peuvent s'exécuter dès qu'ils obtiennent le temps CPU.
Différences entre le blocage et le spinning dans les modes de synchronisation :
- Blocage : Les verrous de blocage réduisent l'utilisation CPU des threads en attente, évitant une surcharge CPU, mais avec des temps de transition plus lents. Ils offrent de meilleures performances en cas de forte concurrence.
- Spinning : Le premier thread verrouille la ressource ; si un second tente de verrouiller, il boucle en attente jusqu'à la libération. Le spinning maintient le thread en exécution active, offrant une réponse rapide, mais dégrade les performances avec une augmentation du nombre de threads due à l'occupation CPU. Il est adapté aux situations de faible concurrence et de courtes durées de verrouillage.
Propriétés fondamentales de la concurrence
- Atomicité : Une opération ou un ensemble d'opérations s'exécutent intégralement sans interruption, ou ne s'exécutent pas du tout.
- Ordre : L'exécution du programme suit la séquence du code source, sans réordonnancement des instructions.
- Visibilité : Lorsque plusieurs threads accèdent à une variable partagée, toute modification par un thread est immédiatement visible aux autres.
Principe de fonctionnement du mot-clé synchronized
Le mot-clé synchronized garantit que seule une méthode ou un bloc de code peut être exécuté à un instant donné par un seul thread, assurant également la visibilité des variables partagées. Voici des exemples illustrant son utilisation :
// Méthode synchronisée instance : verrou sur l'objet courant
public class CalculateurSync {
private int total = 0;
public synchronized void incrementer() {
total++;
}
}
// Méthode synchronisée statique : verrou sur l'objet Class
public class CalculateurStatique {
private int total = 0;
public static synchronized void augmenter() {
total++;
}
}
// Bloc synchronisé : verrou sur un objet spécifique
public class CalculateurBloc {
private int total = 0;
private final Object verrou = new Object();
public void ajouter() {
synchronized(verrou) {
total++;
}
}
}
Pour un bloc synchronisé, les instructions monitorenter et monitorexit sont insérées respectivement au début et à la fin du bloc. La JVM assure une correspondance stricte entre chaque monitorenter et un monitorexit. Tout objet possède un moniteur associé ; lorsqu'un thread atteint monitorenter, il tente d'acquérir la propriété du moniteur, c'est-à-dire le verrou de l'objet.
Pour une méthode synchronisée, la synchronisation est indiquée par le drapeau ACC_SYNCHRONIZED dans le pool de constantes. La JVM vérifie ce drapeau lors de l'appel et applique le même mécanisme que pour les blocs synchronisés. À l'origine, synchronized est un verrou lourd, mais des optimisations introduites dans JDK 1.6, telles que le spinning adaptatif, l'élimination de verrou, l'élargissement de verrou, les verrous biaisés et légers, réduisent les surcoûts.
Modèle de mémoire Java (JMM) en résumé
Le JMM définit les interactions entre la mémoire de travail des threads et la mémoire principale, ainsi que la visibilité et l'ordre d'exécution. Il offre aux programmeurs des garanties fortes de visibilité mémoire tout en relaxant les contraintes pour les compilateurs et processeurs. En masquant les spécificités des mémoires CPU et OS, le JMM permet aux programmes de fonctionner correctement sur diverses architectures. Java utilise un modèle de mémoire partagée pour la communication inter-threads, où les threads ne communiquent pas directement.