Java 101: Comprendre les threads Java, Partie 2: Synchronisation des threads

Le mois dernier, je vous ai montré à quel point il est facile de créer des objets de thread, de démarrer des threads qui s'associent à ces objets en appelant Threadla start()méthode de et d'effectuer des opérations de thread simples en appelant d'autres Threadméthodes telles que les trois join()méthodes surchargées . Ce mois-ci, nous nous attaquons à des programmes Java multithread, qui sont plus complexes.

Comprendre les threads Java - lire toute la série

  • Partie 1: Présentation des threads et des exécutables
  • Partie 2: Synchronisation des threads
  • Partie 3: Planification des threads, attente / notification et interruption des threads
  • Partie 4: Groupes de threads, volatilité, variables locales de thread, minuteries et mort de thread

Les programmes multithreads fonctionnent souvent de manière erratique ou produisent des valeurs erronées en raison du manque de synchronisation des threads . La synchronisation consiste à sérialiser (ou à ordonner un à la fois) l'accès des threads à ces séquences de code qui permettent à plusieurs threads de manipuler les variables de champ de classe et d'instance et d'autres ressources partagées. J'appelle ces séquences de code sections de code critiques. . La colonne de ce mois concerne l'utilisation de la synchronisation pour sérialiser l'accès aux threads aux sections de code critiques de vos programmes.

Je commence par un exemple qui illustre pourquoi certains programmes multithread doivent utiliser la synchronisation. J'explore ensuite le mécanisme de synchronisation de Java en termes de moniteurs et de verrous, et du synchronizedmot - clé. Parce que l'utilisation incorrecte du mécanisme de synchronisation annule ses avantages, je conclus en examinant deux problèmes qui résultent d'une telle mauvaise utilisation.

Conseil: contrairement aux variables de champ de classe et d'instance, les threads ne peuvent pas partager les variables et paramètres locaux. La raison: les variables et paramètres locaux sont alloués sur la pile d'appels de méthode d'un thread. En conséquence, chaque thread reçoit sa propre copie de ces variables. En revanche, les threads peuvent partager des champs de classe et des champs d'instance car ces variables ne sont pas allouées sur la pile d'appels de méthode d'un thread. Au lieu de cela, ils allouent dans la mémoire de tas partagée - dans le cadre de classes (champs de classe) ou d'objets (champs d'instance).

Le besoin de synchronisation

Pourquoi avons-nous besoin de synchronisation? Pour une réponse, considérons cet exemple: Vous écrivez un programme Java qui utilise une paire de threads pour simuler le retrait / dépôt de transactions financières. Dans ce programme, un thread effectue des dépôts tandis que l'autre effectue des retraits. Chaque thread manipule une paire de variables partagées, des variables de champ de classe et d'instance, qui identifient le nom et le montant de la transaction financière. Pour une transaction financière correcte, chaque thread doit finir d'affecter des valeurs aux variables nameet amount(et imprimer ces valeurs, pour simuler l'enregistrement de la transaction) avant que l'autre thread ne commence à affecter des valeurs à nameet amount(et à imprimer ces valeurs). Après quelques travaux, vous vous retrouvez avec un code source qui ressemble au Listing 1:

Liste 1. NeedForSynchronizationDemo.java

// NeedForSynchronizationDemo.java class NeedForSynchronizationDemo { public static void main (String [] args) { FinTrans ft = new FinTrans (); TransThread tt1 = new TransThread (ft, "Deposit Thread"); TransThread tt2 = new TransThread (ft, "Withdrawal Thread"); tt1.start (); tt2.start (); } } class FinTrans { public static String transName; public static double amount; } class TransThread extends Thread { private FinTrans ft; TransThread (FinTrans ft, String name) { super (name); // Save thread's name this.ft = ft; // Save reference to financial transaction object } public void run () { for (int i = 0; i < 100; i++) { if (getName ().equals ("Deposit Thread")) { // Start of deposit thread's critical code section ft.transName = "Deposit"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 2000.0; System.out.println (ft.transName + " " + ft.amount); // End of deposit thread's critical code section } else { // Start of withdrawal thread's critical code section ft.transName = "Withdrawal"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 250.0; System.out.println (ft.transName + " " + ft.amount); // End of withdrawal thread's critical code section } } } }

NeedForSynchronizationDemoLe code source de a deux sections de code critiques: une accessible au thread de dépôt et l'autre accessible au thread de retrait. Dans la section de code critique du thread de dépôt, ce thread affecte la DepositStringréférence de l' objet à une variable partagée transNameet l'assigne 2000.0à une variable partagée amount. De même, dans la section de code critique du thread de retrait, ce thread assigne la WithdrawalStringréférence de l' objet transNameet l'assigne 250.0à amount. Après les affectations de chaque thread, le contenu de ces variables s'imprime. Lorsque vous exécutez NeedForSynchronizationDemo, vous pouvez vous attendre à une sortie similaire à une liste de lignes Withdrawal 250.0et d' intercalaires Deposit 2000.0. Au lieu de cela, vous recevez une sortie ressemblant à ce qui suit:

Withdrawal 250.0 Withdrawal 2000.0 Deposit 2000.0 Deposit 2000.0 Deposit 250.0

Le programme a définitivement un problème. Le fil de retrait ne doit pas simuler des retraits de 2 000 $ et le fil de dépôt ne doit pas simuler des dépôts de 250 $. Chaque thread produit une sortie incohérente. Qu'est-ce qui cause ces incohérences? Considérer ce qui suit:

  • Sur une machine à processeur unique, les threads partagent le processeur. En conséquence, un thread ne peut s'exécuter que pendant une certaine période. À ce moment-là, la JVM / système d'exploitation interrompt l'exécution de ce thread et permet à un autre thread de s'exécuter - une manifestation de la planification des threads, un sujet que j'aborde dans la partie 3. Sur une machine multiprocesseur, en fonction du nombre de threads et de processeurs, chaque thread peut avoir son propre processeur.
  • Sur une machine à processeur unique, la période d'exécution d'un thread peut ne pas durer assez longtemps pour que ce thread termine l'exécution de sa section de code critique avant qu'un autre thread ne commence à exécuter sa propre section de code critique. Sur une machine multiprocesseur, les threads peuvent exécuter simultanément du code dans leurs sections de code critiques. Cependant, ils peuvent entrer leurs sections de code critiques à des moments différents.
  • Sur les machines monoprocesseur ou multiprocesseur, le scénario suivant peut se produire: Thread A attribue une valeur à la variable partagée X dans sa section de code critique et décide d'effectuer une opération d'entrée / sortie qui nécessite 100 millisecondes. Le thread B entre ensuite dans sa section de code critique, attribue une valeur différente à X, effectue une opération d'entrée / sortie de 50 millisecondes et attribue des valeurs aux variables partagées Y et Z. L'opération d'entrée / sortie du thread A se termine, et ce thread affecte la sienne valeurs à Y et Z. Étant donné que X contient une valeur attribuée à B, tandis que Y et Z contiennent des valeurs affectées à A, il en résulte une incohérence.

Comment une incohérence survient-elle NeedForSynchronizationDemo? Supposons que le thread de dépôt s'exécute ft.transName = "Deposit";puis appelle Thread.sleep(). À ce stade, le thread de dépôt abandonne le contrôle du processeur pendant la période de temps pendant laquelle il doit dormir, et le thread de retrait s'exécute. Supposons que le thread de dépôt dort pendant 500 millisecondes (une valeur sélectionnée au hasard, grâce à Math.random(), de la plage inclusive de 0 à 999 millisecondes; j'explore Mathet sa random()méthode dans un prochain article) Pendant le temps de repos du thread de dépôt, le thread de retrait s'exécute ft.transName = "Withdrawal";, dort pendant 50 millisecondes (la valeur de sommeil choisie au hasard par le thread de retrait), se réveille, s'exécute ft.amount = 250.0;et s'exécute, le System.out.println (ft.transName + " " + ft.amount);tout avant que le thread de dépôt ne se réveille. En conséquence, le fil de retrait imprimeWithdrawal 250.0, qui est correct. Lorsque le thread de dépôt se réveille, il s'exécute ft.amount = 2000.0;, suivi de System.out.println (ft.transName + " " + ft.amount);. Cette fois, Withdrawal 2000.0imprime, ce qui n'est pas correct. Bien que le thread de dépôt affectait auparavant la "Deposit"référence à transName, cette référence a ensuite disparu lorsque le thread de retrait affectait la "Withdrawal"référence de à cette variable partagée. Lorsque le thread de dépôt s'est réveillé, il n'a pas réussi à restaurer la référence correcte à transName, mais a poursuivi son exécution en affectant 2000.0à amount. Bien qu'aucune des deux variables n'ait une valeur non valide, les valeurs combinées des deux variables représentent une incohérence. Dans ce cas, leurs valeurs représentent une tentative de retrait de 000.

Il y a longtemps, les informaticiens ont inventé un terme pour décrire les comportements combinés de plusieurs threads qui conduisent à des incohérences. Ce terme est la condition de concurrence - l'acte de chaque thread en course pour terminer sa section de code critique avant qu'un autre thread n'entre dans cette même section de code critique. CommeNeedForSynchronizationDemodémontre que les ordres d'exécution des threads sont imprévisibles. Il n'y a aucune garantie qu'un thread puisse terminer sa section de code critique avant qu'un autre thread n'entre dans cette section. Par conséquent, nous avons une condition de race, ce qui provoque des incohérences. Pour éviter les conditions de concurrence critique, chaque thread doit terminer sa section de code critique avant qu'un autre thread n'entre dans la même section de code critique ou dans une autre section de code critique associée qui manipule les mêmes variables ou ressources partagées. En l'absence de moyen de sérialiser l'accès (c'est-à-dire d'autoriser l'accès à un seul thread à la fois) à une section de code critique, vous ne pouvez pas empêcher les conditions de concurrence ou les incohérences. Heureusement, Java fournit un moyen de sérialiser l'accès aux threads: grâce à son mécanisme de synchronisation.

Remarque : Parmi les types de Java, seules les variables à virgule flottante longue et double précision sont sujettes à des incohérences. Pourquoi? Une machine virtuelle Java 32 bits accède généralement à une variable entière de 64 bits ou à une variable à virgule flottante double précision de 64 bits en deux étapes de 32 bits adjacentes. Un thread peut terminer la première étape, puis attendre pendant qu'un autre thread exécute les deux étapes. Ensuite, le premier thread peut se réveiller et terminer la deuxième étape, produisant une variable avec une valeur différente de la valeur du premier ou du second thread. Par conséquent, si au moins un thread peut modifier une variable entière longue ou une variable à virgule flottante double précision, tous les threads qui lisent et / ou modifient cette variable doivent utiliser la synchronisation pour sérialiser l'accès à la variable.

Mécanisme de synchronisation de Java

Java fournit un mécanisme de synchronisation pour empêcher plus d'un thread d'exécuter du code dans une ou plusieurs sections de code critiques à tout moment. Ce mécanisme se fonde sur les concepts d'écrans et de serrures. Considérez un moniteur comme un emballage protecteur autour d'une section de code critique et d'un verrouen tant qu'entité logicielle utilisée par un moniteur pour empêcher plusieurs threads d'entrer dans le moniteur. L'idée est la suivante: lorsqu'un thread souhaite entrer dans une section de code critique protégée par un moniteur, ce thread doit acquérir le verrou associé à un objet qui s'associe au moniteur. (Chaque objet a son propre verrou.) Si un autre thread détient ce verrou, la JVM force le thread demandeur à attendre dans une zone d'attente associée au moniteur / verrou. Lorsque le thread du moniteur libère le verrou, la JVM supprime le thread en attente de la zone d'attente du moniteur et permet à ce thread d'acquérir le verrou et de passer à la section de code critique du moniteur.

Pour travailler avec des moniteurs / verrous, la JVM fournit les instructions monitorenteret monitorexit. Heureusement, vous n'avez pas besoin de travailler à un niveau aussi bas. Au lieu de cela, vous pouvez utiliser le synchronizedmot - clé Java dans le contexte de l' synchronizedinstruction et des méthodes synchronisées.

L'instruction synchronisée

Certaines sections de code critiques occupent de petites portions de leurs méthodes englobantes. Pour protéger l'accès de plusieurs threads à ces sections de code critiques, vous utilisez l' synchronizedinstruction. Cette déclaration a la syntaxe suivante:

'synchronized' '(' objectidentifier ')' '{' // Critical code section '}'

L' synchronizedinstruction commence par un mot synchronized- clé et se poursuit par un identificateur d'objet, qui apparaît entre une paire de parenthèses. L' identificateur d'objet fait référence à un objet dont le verrou est associé au moniteur synchronizedreprésenté par l' instruction. Enfin, la section de code critique des instructions Java apparaît entre une paire de caractères d'accolade. Comment interprétez-vous cette synchronizeddéclaration? Considérez le fragment de code suivant:

synchronized ("sync object") { // Access shared variables and other shared resources }