Recherche…


Piège: utilisation incorrecte de wait () / notify ()

Les méthodes object.wait() , object.notify() et object.notifyAll() sont destinées à être utilisées de manière très spécifique. (voir http://stackoverflow.com/documentation/java/5409/wait-notify#t=20160811161648303307 )

Le problème "Notification perdue"

Une erreur courante des débutants consiste à appeler inconditionnellement object.wait()

private final Object lock = new Object();

public void myConsumer() {
    synchronized (lock) {
        lock.wait();     // DON'T DO THIS!!
    }
    doSomething();
}

La raison en est que cela dépend d'un autre thread à appeler lock.notify() ou lock.notifyAll() , mais rien ne garantit que l'autre thread n'a pas effectué cet appel avant le thread consommateur appelé lock.wait() .

lock.notify() et lock.notifyAll() ne font rien du tout si un autre thread n'attend pas déjà la notification. Le thread qui appelle myConsumer() dans cet exemple sera bloqué pour toujours s'il est trop tard pour intercepter la notification.

Le bogue "État de surveillance illégal"

Si vous appelez wait() ou notify() sur un objet sans le verrouiller, la machine IllegalMonitorStateException .

public void myConsumer() {
    lock.wait();      // throws exception
    consume();
}

public void myProducer() {
    produce();
    lock.notify();    // throws exception
}

(La conception de wait() / notify() exige que le verrou soit maintenu car cela est nécessaire pour éviter les conditions de concurrence systémiques. S'il était possible d'appeler wait() ou notify() sans verrouiller, il serait impossible de l'implémenter le principal cas d'utilisation de ces primitives: attendre qu'une condition se produise.)

Attendre / notifier est trop bas

La meilleure façon d'éviter les problèmes avec wait() et notify() est de ne pas les utiliser. La plupart des problèmes de synchronisation peuvent être résolus en utilisant les objets de synchronisation de niveau supérieur (files d'attente, barrières, sémaphores, etc.) disponibles dans le package java.utils.concurrent .

Piège - Extension de 'java.lang.Thread'

Le javadoc pour la classe Thread montre deux façons de définir et d'utiliser un thread:

Utiliser une classe de thread personnalisée:

 class PrimeThread extends Thread {
     long minPrime;
     PrimeThread(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeThread p = new PrimeThread(143);
 p.start();

Utiliser un Runnable :

 class PrimeRun implements Runnable {
     long minPrime;
     PrimeRun(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeRun p = new PrimeRun(143);
 new Thread(p).start();

(Source: java.lang.Thread javadoc .)

L’approche de classe de thread personnalisée fonctionne, mais elle pose quelques problèmes:

  1. Il est difficile d'utiliser PrimeThread dans un contexte qui utilise un pool de threads classique, un exécuteur ou le framework ForkJoin. (Ce n'est pas impossible, car PrimeThread implémente indirectement Runnable , mais l'utilisation d'une classe Thread personnalisée en tant que Runnable est certainement maladroite et peut ne pas être viable ... selon les autres aspects de la classe.)

  2. Il y a plus de possibilités d'erreurs dans d'autres méthodes. Par exemple, si vous déclarez un PrimeThread.start() sans déléguer à Thread.start() , vous obtiendrez un "thread" qui s'exécutera sur le thread en cours.

L'approche consistant à placer la logique de thread dans un Runnable évite ces problèmes. En effet, si vous utilisez une classe anonyme (Java 1.1 et supérieur) pour implémenter le Runnable le résultat est plus succinct et plus lisible que les exemples ci-dessus.

 final long minPrime = ...
 new Thread(new Runnable() {
     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }.start();

Avec une expression lambda (Java 8 et suivants), l'exemple ci-dessus deviendrait encore plus élégant:

 final long minPrime = ...
 new Thread(() -> {
    // compute primes larger than minPrime
     . . .
 }).start();

Pitfall - Trop de threads rendent une application plus lente.

Beaucoup de personnes novices en matière de multithread pensent que l'utilisation de threads accélère le processus. En fait, c'est beaucoup plus compliqué que ça. Mais une chose que nous pouvons affirmer avec certitude est que, pour tout ordinateur, le nombre de threads pouvant être exécutés simultanément est limité:

  • Un ordinateur a un nombre fixe de cœurs (ou hyperthreads ).
  • Un thread Java doit être planifié sur un core ou un hyperthread pour pouvoir s'exécuter.
  • Si les threads Java exécutables sont plus nombreux que les cores / hyperthreads (disponibles), certains doivent attendre.

Cela nous indique que la création de plus en plus de threads Java ne permet pas d’ accélérer l’application. Mais il y a aussi d'autres considérations:

  • Chaque thread nécessite une région de mémoire hors tas pour sa pile de threads. La taille de pile de threads standard (par défaut) est de 512 Ko ou 1 Mo. Si vous avez un nombre important de threads, l'utilisation de la mémoire peut être importante.

  • Chaque thread actif fera référence à un certain nombre d'objets dans le tas. Cela augmente l'ensemble de travail des objets accessibles , ce qui a un impact sur la récupération de place et l'utilisation de la mémoire physique.

  • Les frais généraux de commutation entre les threads ne sont pas triviaux. Cela implique généralement un basculement dans l'espace du noyau du système d'exploitation pour prendre une décision de planification des threads.

  • Les surcharges de la synchronisation des threads et de la signalisation inter-thread (par exemple wait (), notify () / notifyAll) peuvent être importantes.

Selon les détails de votre application, ces facteurs signifient généralement qu'il existe un «point idéal» pour le nombre de threads. Au-delà de cela, l'ajout de nouveaux threads apporte une amélioration minime des performances et peut aggraver les performances.

Si votre application crée pour chaque nouvelle tâche, une augmentation imprévue de la charge de travail (par exemple un taux de demande élevé) peut entraîner un comportement catastrophique.

Une meilleure façon de gérer cela est d'utiliser un pool de threads borné dont vous pouvez contrôler la taille (de manière statique ou dynamique). Lorsqu'il y a trop de travail à faire, l'application doit mettre les demandes en attente. Si vous utilisez un ExecutorService , il se chargera de la gestion du pool de threads et de la mise en file d'attente des tâches.

Piège - La création de fils est relativement coûteuse

Considérons ces deux micro-repères:

Le premier benchmark crée, démarre et rejoint simplement les threads. Le thread Runnable ne fonctionne pas.

public class ThreadTest {
    public static void main(String[] args) throws Exception {
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Thread t = new Thread(new Runnable() {
                        public void run() {
                }});
                t.start();
                t.join();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ThreadTest 
34627.91355
33596.66021
33661.19084
33699.44895
33603.097
33759.3928
33671.5719
33619.46809
33679.92508
33500.32862
33409.70188
33475.70541
33925.87848
33672.89529
^C

Sur un PC moderne typique fonctionnant sous Linux avec Java 8 bits 64 u101, ce test montre un temps moyen de création, de démarrage et de jonction de threads compris entre 33,6 et 33,9 microsecondes.

Le second test fait l'équivalent du premier mais utilise un ExecutorService pour soumettre des tâches et un Future pour rejoindre la fin de la tâche.

import java.util.concurrent.*;

public class ExecutorTest {
    public static void main(String[] args) throws Exception {
        ExecutorService exec = Executors.newCachedThreadPool();
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Future<?> future = exec.submit(new Runnable() {
                    public void run() {
                    }
                });
                future.get();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ExecutorTest
6714.66053
5418.24901
5571.65213
5307.83651
5294.44132
5370.69978
5291.83493
5386.23932
5384.06842
5293.14126
5445.17405
5389.70685
^C

Comme vous pouvez le voir, les moyennes se situent entre 5,3 et 5,6 microsecondes.

Bien que les temps réels dépendent de divers facteurs, la différence entre ces deux résultats est significative. Il est clairement plus rapide d'utiliser un pool de threads pour recycler des threads que de créer de nouveaux threads.

Piège: les variables partagées nécessitent une synchronisation correcte

Considérez cet exemple:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (!stop) {
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        tt.stop = true;            // Tell child thread to stop.
    }
}

L'intention de ce programme est de démarrer un thread, de le laisser fonctionner pendant 1000 millisecondes, puis de l'arrêter en définissant l'indicateur d' stop .

Cela fonctionnera-t-il comme prévu?

Peut-être que oui, peut-être que non.

Une application ne s'arrête pas nécessairement lorsque la méthode main revient. Si un autre thread a été créé et que ce thread n'a pas été marqué comme un thread de démon, l'application continuera à s'exécuter une fois le thread principal terminé. Dans cet exemple, cela signifie que l'application continuera à s'exécuter jusqu'à la fin du thread enfant. Cela devrait se tt.stop lorsque tt.stop est défini sur true .

Mais ce n'est pas vraiment vrai. En fait, le thread enfant s'arrête après avoir observé l' stop avec la valeur true . Est-ce que ça va arriver? Peut-être que oui, peut-être que non.

La spécification de langage Java garantit que les lectures et écritures de mémoire effectuées dans un thread sont visibles pour ce thread, conformément à l'ordre des instructions dans le code source. Cependant, en général, cela n'est PAS garanti lorsqu'un thread écrit et qu'un autre thread (ultérieurement) lit. Pour que la visibilité soit garantie, il doit exister une chaîne d' événements-avant les relations entre une écriture et une lecture ultérieure. Dans l'exemple ci-dessus, il n'existe pas de chaîne de ce type pour la mise à jour de l'indicateur d' stop . Par conséquent, il n'est pas garanti que le thread enfant verra stop change à true .

(Note aux auteurs: il devrait y avoir une rubrique distincte sur le modèle de mémoire Java pour entrer dans les détails techniques approfondis.)

Comment pouvons-nous résoudre le problème?

Dans ce cas, il existe deux méthodes simples pour s’assurer que l’ stop mise à jour est visible:

  1. Déclarez stop d'être volatile ; c'est à dire

     private volatile boolean stop = false;
    

    Pour une variable volatile , le JLS spécifie qu'il y a une relation " passe-avant" entre un thread écrit par un et une lecture ultérieure par un second thread.

  2. Utilisez un mutex pour synchroniser comme suit:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (true) {
            synchronize (this) {
                if (stop) {
                    break;
                }
            }
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        synchronize (tt) {
            tt.stop = true;        // Tell child thread to stop.
        }
    }
}

En plus de s’assurer de l’exclusion mutuelle, le JLS spécifie qu’il existe une relation avant-après entre la libération d’un mutex dans un thread et l’obtention du même mutex dans un second thread.

Mais l'assignation n'est-elle pas atomique?

Oui, ça l'est!

Cependant, cela ne signifie pas que les effets de la mise à jour seront visibles simultanément sur tous les threads. Seule une chaîne appropriée de relations préalables garantira cela.

Pourquoi ont-ils fait ça?

Les programmeurs qui font de la programmation multithread en Java pour la première fois trouvent le modèle de mémoire difficile. Les programmes se comportent de manière non intuitive car l'attente naturelle est que les écritures soient visibles de manière uniforme. Alors, pourquoi les concepteurs Java conçoivent le modèle de mémoire de cette manière.

Cela se résume à un compromis entre performance et facilité d'utilisation (pour le programmeur).

Une architecture informatique moderne se compose de plusieurs processeurs (cœurs) avec des ensembles de registres individuels. La mémoire principale est accessible à tous les processeurs ou à des groupes de processeurs. Une autre propriété du matériel informatique moderne est que l'accès aux registres est généralement plus rapide que l'accès à la mémoire principale. À mesure que le nombre de cœurs évolue, il est facile de voir que lire et écrire dans la mémoire principale peut devenir le principal goulot d'étranglement des performances d'un système.

Cette incompatibilité est résolue en implémentant un ou plusieurs niveaux de mémoire cache entre les cœurs du processeur et la mémoire principale. Chaque cœur accède aux cellules mémoire via son cache. Normalement, une lecture en mémoire principale se produit uniquement en cas d’échec du cache et une écriture en mémoire principale ne se produit que si une ligne de cache doit être vidée. Pour une application dans laquelle le jeu d'emplacements de mémoire de chaque cœur peut tenir dans son cache, la vitesse de base n'est plus limitée par la vitesse / la bande passante de la mémoire principale.

Mais cela nous pose un nouveau problème lorsque plusieurs cœurs lisent et écrivent des variables partagées. La dernière version d'une variable peut se trouver dans le cache d'un cœur. À moins que ce noyau vide la ligne de cache dans la mémoire principale ET que d'autres cœurs invalident leur copie en cache des versions antérieures, certains d'entre eux risquent de voir des versions obsolètes de la variable. Mais si les caches étaient vidés en mémoire chaque fois qu'il y avait une écriture en cache ("juste au cas où" il y aurait une lecture par un autre noyau), cela consommerait inutilement la bande passante de la mémoire principale.

La solution standard utilisée au niveau du jeu d'instructions matérielles consiste à fournir des instructions pour l'invalidation du cache et la mise en cache du cache, et laisse le compilateur décider du moment où il doit les utiliser.

Retour à Java Le modèle de mémoire est conçu pour que les compilateurs Java ne soient pas obligés d'émettre des instructions d'invalidation de cache et d'écriture directe lorsqu'ils ne sont pas vraiment nécessaires. L'hypothèse est que le programmeur utilisera un mécanisme de synchronisation approprié (par exemple des mutex primitifs, volatile classes de concurrence de niveau supérieur volatile , etc.) pour indiquer qu'il a besoin de visibilité de la mémoire. En l'absence d'une relation se produit avant , les compilateurs Java sont libres de supposer qu'aucune opération de cache (ou similaire) n'est requise.

Cela présente des avantages significatifs en termes de performances pour les applications multithread, mais l'inconvénient est que l'écriture d'applications multithread correctes n'est pas simple. Le programmeur doit comprendre ce qu'il fait.

Pourquoi ne puis-je pas reproduire cela?

Il existe un certain nombre de raisons pour lesquelles de tels problèmes sont difficiles à reproduire:

  1. Comme expliqué ci-dessus, le fait de ne pas traiter correctement les problèmes de visibilité de la mémoire signifie généralement que votre application compilée ne gère pas correctement les caches de mémoire. Cependant, comme nous l’avons mentionné plus haut, les caches de mémoire sont souvent vidés.

  2. Lorsque vous modifiez la plate-forme matérielle, les caractéristiques des caches mémoire peuvent changer. Cela peut entraîner un comportement différent si votre application ne se synchronise pas correctement.

  3. Vous observez peut-être les effets d'une synchronisation fortuite . Par exemple, si vous ajoutez des traces de trace, il se produit généralement une synchronisation en arrière-plan dans les flux d'E / S qui provoque des vidages de cache. Ainsi, l'ajout de traces rend souvent l'application différente.

  4. L'exécution d'une application sous un débogueur le compile différemment par le compilateur JIT. Les points d'arrêt et les étapes simples aggravent la situation. Ces effets changeront souvent le comportement d'une application.

Ces problèmes rendent les bogues dus à une synchronisation inadéquate particulièrement difficile à résoudre.



Modified text is an extract of the original Stack Overflow Documentation
Sous licence CC BY-SA 3.0
Non affilié à Stack Overflow