Le thread Java exécutant une opération de reste dans une boucle bloque tous les autres threads

123

L'extrait de code suivant exécute deux threads, l'un est un simple enregistrement de minuterie toutes les secondes, le second est une boucle infinie qui exécute une opération de reste:

public class TestBlockingThread {
    private static final Logger LOGGER = LoggerFactory.getLogger(TestBlockingThread.class);

    public static final void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            int i = 0;
            while (true) {
                i++;
                if (i != 0) {
                    boolean b = 1 % i == 0;
                }
            }
        };

        new Thread(new LogTimer()).start();
        Thread.sleep(2000);
        new Thread(task).start();
    }

    public static class LogTimer implements Runnable {
        @Override
        public void run() {
            while (true) {
                long start = System.currentTimeMillis();
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // do nothing
                }
                LOGGER.info("timeElapsed={}", System.currentTimeMillis() - start);
            }
        }
    }
}

Cela donne le résultat suivant:

[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1004
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1003
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=13331
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1006
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1003
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1004
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1004

Je ne comprends pas pourquoi la tâche infinie bloque tous les autres threads pendant 13,3 secondes. J'ai essayé de changer les priorités des threads et d'autres paramètres, rien n'a fonctionné.

Si vous avez des suggestions pour résoudre ce problème (y compris la modification des paramètres de changement de contexte du système d'exploitation), veuillez me le faire savoir.

kms333
la source
8
@Marthin Pas GC. C'est JIT. En cours d'exécution avec, -XX:+PrintCompilationj'obtiens ce qui suit à la fin du délai prolongé: TestBlockingThread :: lambda $ 0 @ 2 (24 octets) COMPILE SKIPPED: boucle infinie triviale (réessayer à un niveau différent)
Andreas
4
Il se reproduit sur mon système, le seul changement étant que j'ai remplacé l'appel de journal par System.out.println. Cela ressemble à un problème de planificateur car si vous introduisez un sommeil de 1 ms à l'intérieur de la boucle while (true) du Runnable, la pause dans l'autre thread disparaît.
JJF le
3
Non pas que je le recommande, mais si vous désactivez l' utilisation de JIT -Djava.compiler=NONE, cela ne se produira pas.
Andreas
3
Vous pouvez censément désactiver JIT pour une seule méthode. Voir Désactiver Java JIT pour une méthode / classe spécifique?
Andreas
3
Il n'y a pas de division entière dans ce code. Veuillez corriger votre titre et votre question.
Marquis of Lorne le

Réponses:

94

Après toutes les explications ici (grâce à Peter Lawrey ), nous avons constaté que la source principale de cette pause est que le point de sécurité à l'intérieur de la boucle est atteint assez rarement, il faut donc beaucoup de temps pour arrêter tous les threads pour le remplacement du code compilé par JIT.

Mais j'ai décidé d'aller plus loin et de découvrir pourquoi le point de sécurité est rarement atteint. J'ai trouvé un peu déroutant pourquoi le saut arrière de la whileboucle n'est pas "sûr" dans ce cas.

Alors j'invoque -XX:+PrintAssemblydans toute sa gloire pour aider

-XX:+UnlockDiagnosticVMOptions \
-XX:+TraceClassLoading \
-XX:+DebugNonSafepoints \
-XX:+PrintCompilation \
-XX:+PrintGCDetails \
-XX:+PrintStubCode \
-XX:+PrintAssembly \
-XX:PrintAssemblyOptions=-Mintel

Après quelques recherches, j'ai trouvé qu'après la troisième recompilation du C2compilateur lambda , les sondages safepoint étaient complètement rejetés dans la boucle.

METTRE À JOUR

Au cours de l'étape de profilage, la variable in'a jamais été vue égale à 0. C'est pourquoi C2optimisé de manière spéculative cette branche, de sorte que la boucle a été transformée en quelque chose comme

for (int i = OSR_value; i != 0; i++) {
    if (1 % i == 0) {
        uncommon_trap();
    }
}
uncommon_trap();

Notez qu'à l'origine la boucle infinie a été remodelée en une boucle finie régulière avec un compteur! En raison de l'optimisation JIT pour éliminer les sondages de point de sécurité dans les boucles comptées finies, il n'y avait pas non plus de sondage de point de sécurité dans cette boucle.

Après un certain temps, ije suis retourné 0et le piège rare a été pris. La méthode a été désoptimisée et son exécution s'est poursuivie dans l'interprète. Lors de la recompilation avec une nouvelle connaissance C2reconnue la boucle infinie et abandonné la compilation. Le reste de la méthode s'est déroulé dans l'interprète avec des points de sécurité appropriés.

Il existe un excellent article de blog à lire absolument "Safepoints: Signification, effets secondaires et frais généraux" de Nitsan Wakart couvrant les points de sécurité et ce problème particulier.

L'élimination des points de sécurité dans des boucles comptées très longues est connue pour être un problème. Le bogue JDK-5014723(merci à Vladimir Ivanov ) résout ce problème.

La solution de contournement est disponible jusqu'à ce que le bogue soit finalement corrigé.

  1. Vous pouvez essayer d' utiliser -XX:+UseCountedLoopSafepoints(il va faire pénalité de performance globale et peut conduire à l' accident JVM JDK-8161147 ). Après avoir utilisé le C2compilateur, continuez à garder les points de sécurité aux sauts arrière et la pause d'origine disparaît complètement.
  2. Vous pouvez désactiver explicitement la compilation de la méthode problématique en utilisant
    -XX:CompileCommand='exclude,binary/class/Name,methodName'

  3. Ou vous pouvez réécrire votre code en ajoutant manuellement un point de restauration. Par exemple, un Thread.yield()appel à la fin du cycle ou même le passage int ià long i(merci, Nitsan Wakart ) corrigera également la pause.

vsminkov
la source
7
C'est la vraie réponse à la question de savoir comment y remédier .
Andreas
AVERTISSEMENT: ne pas utiliser -XX:+UseCountedLoopSafepointsen production, car cela pourrait planter JVM . La meilleure solution de contournement à ce jour consiste à diviser manuellement la longue boucle en une boucle plus courte.
apangin le
@apangin aah. je l'ai! merci :) c'est pourquoi c2supprime les points de restauration! mais une autre chose que je n'ai pas obtenue est ce qui se passe ensuite. pour autant que je puisse voir, il ne reste plus de points de sécurité après le déroulement de la boucle (?) et il semble qu'il n'y ait aucun moyen de faire du stw. il y a donc une sorte de délai d'attente et une désoptimisation a lieu?
vsminkov
2
Mon commentaire précédent n'était pas exact. Maintenant, ce qui se passe est parfaitement clair. Au stade du profilage, il in'est jamais égal à 0, donc la boucle est transformée de façon spéculative en quelque chose comme for (int i = osr_value; i != 0; i++) { if (1 % i == 0) uncommon_trap(); } uncommon_trap();une boucle comptée finie régulière. Une fois iretourné à 0, le piège inhabituel est pris, la méthode est désoptimisée et poursuivie dans l'interpréteur. Lors de la recompilation avec les nouvelles connaissances, JIT reconnaît la boucle infinie et abandonne la compilation. Le reste de la méthode est exécuté dans l'interpréteur avec des points de sécurité appropriés.
apangin le
1
Vous pouvez simplement créer ia long au lieu d'un int, ce qui rendrait la boucle "non comptée" et résoudrait le problème.
Nitsan Wakart
64

En bref, la boucle que vous avez n'a pas de point sûr à l'intérieur, sauf lorsqu'elle i == 0est atteinte. Lorsque cette méthode est compilée et déclenche le remplacement du code, elle doit amener tous les threads à un point sûr, mais cela prend beaucoup de temps, bloquant non seulement le thread exécutant le code mais tous les threads de la JVM.

J'ai ajouté les options de ligne de commande suivantes.

-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime -XX:+PrintCompilation

J'ai également modifié le code pour utiliser la virgule flottante, ce qui semble prendre plus de temps.

boolean b = 1.0 / i == 0;

Et ce que je vois dans la sortie est

timeElapsed=100
Application time: 0.9560686 seconds
  41423  280 %     4       TestBlockingThread::lambda$main$0 @ -2 (27 bytes)   made not entrant
Total time for which application threads were stopped: 40.3971116 seconds, Stopping threads took: 40.3967755 seconds
Application time: 0.0000219 seconds
Total time for which application threads were stopped: 0.0005840 seconds, Stopping threads took: 0.0000383 seconds
  41424  281 %     3       TestBlockingThread::lambda$main$0 @ 2 (27 bytes)
timeElapsed=40473
  41425  282 %     4       TestBlockingThread::lambda$main$0 @ 2 (27 bytes)
  41426  281 %     3       TestBlockingThread::lambda$main$0 @ -2 (27 bytes)   made not entrant
timeElapsed=100

Remarque: pour que le code soit remplacé, les threads doivent être arrêtés à un point sûr. Cependant, il apparaît ici qu'un tel point de sécurité est atteint très rarement (peut-être uniquement lors du i == 0changement de la tâche en

Runnable task = () -> {
    for (int i = 1; i != 0 ; i++) {
        boolean b = 1.0 / i == 0;
    }
};

Je vois un retard similaire.

timeElapsed=100
Application time: 0.9587419 seconds
  39044  280 %     4       TestBlockingThread::lambda$main$0 @ -2 (28 bytes)   made not entrant
Total time for which application threads were stopped: 38.0227039 seconds, Stopping threads took: 38.0225761 seconds
Application time: 0.0000087 seconds
Total time for which application threads were stopped: 0.0003102 seconds, Stopping threads took: 0.0000105 seconds
timeElapsed=38100
timeElapsed=100

En ajoutant du code à la boucle avec précaution, vous obtenez un délai plus long.

for (int i = 1; i != 0 ; i++) {
    boolean b = 1.0 / i / i == 0;
}

obtient

 Total time for which application threads were stopped: 59.6034546 seconds, Stopping threads took: 59.6030773 seconds

Cependant, modifiez le code pour utiliser une méthode native qui a toujours un point de sécurité (si ce n'est pas un intrinsèque)

for (int i = 1; i != 0 ; i++) {
    boolean b = Math.cos(1.0 / i) == 0;
}

impressions

Total time for which application threads were stopped: 0.0001444 seconds, Stopping threads took: 0.0000615 seconds

Remarque: l'ajout if (Thread.currentThread().isInterrupted()) { ... }à une boucle ajoute un point sûr.

Remarque: cela s'est produit sur une machine à 16 cœurs, il n'y a donc pas de manque de ressources CPU.

Peter Lawrey
la source
1
C'est donc un bogue JVM, non? Où «bogue» signifie un problème de qualité sévère de mise en œuvre et non une violation des spécifications.
usr le
1
@vsminkov être capable d'arrêter le monde pendant plusieurs minutes en raison du manque de points de sécurité semble devoir être traité comme un bug. Le runtime est chargé d'introduire des safepoints pour éviter de longues attentes.
Voo le
1
@Voo, mais d'un autre côté, garder des points de sécurité à chaque saut arrière peut coûter beaucoup de cycles CPU et entraîner une dégradation notable des performances de toute l'application. mais je suis d'accord avec vous. dans ce cas particulier, il semble légitime de conserver safepoint
vsminkov
9
@Voo well ... Je me souviens toujours de cette image en ce qui concerne les optimisations de performances: D
vsminkov
1
.NET insère des points de restauration ici (mais .NET a un code généré lentement). Une solution possible est de fragmenter la boucle. Divisez en deux boucles, faites en sorte que l'intérieur ne vérifie pas les lots de 1024 éléments et que la boucle externe gère les lots et les points de restauration. Réduit les frais généraux conceptuellement de 1024x, moins en pratique.
usr le
26

J'ai trouvé la réponse pour savoir pourquoi . Ils sont appelés safepoints et sont mieux connus sous le nom de Stop-The-World qui se produit à cause de GC.

Voir cet article: Journalisation des pauses stop-the-world dans JVM

Différents événements peuvent amener la JVM à suspendre tous les threads d'application. Ces pauses sont appelées pauses Stop-The-World (STW). La cause la plus courante de déclenchement d'une pause STW est le garbage collection (exemple dans github), mais différentes actions JIT (exemple), la révocation de verrouillage biaisée (exemple), certaines opérations JVMTI et bien d'autres nécessitent également l'arrêt de l'application.

Les points auxquels les threads d'application peuvent être arrêtés en toute sécurité sont appelés, surprise, safepoints . Ce terme est également souvent utilisé pour désigner toutes les pauses STW.

Il est plus ou moins courant que les journaux GC soient activés. Cependant, cela ne capture pas les informations sur tous les points de restauration. Pour tout obtenir, utilisez ces options JVM:

-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime

Si vous vous interrogez sur la dénomination faisant explicitement référence à GC, ne vous inquiétez pas - l'activation de ces options enregistre tous les points de restauration, pas seulement les pauses de ramasse-miettes. Si vous exécutez un exemple suivant (source dans github) avec les indicateurs spécifiés ci-dessus.

En lisant le glossaire des termes HotSpot , il définit ceci:

point de restauration

Un point pendant l'exécution du programme où toutes les racines GC sont connues et tous les contenus des objets de tas sont cohérents. D'un point de vue global, tous les threads doivent se bloquer à un point de restauration avant que le GC puisse s'exécuter. (Dans un cas particulier, les threads exécutant du code JNI peuvent continuer à s'exécuter, car ils n'utilisent que des poignées. Pendant un safepoint, ils doivent bloquer au lieu de charger le contenu du handle.) D'un point de vue local, un safepoint est un point distinctif dans un bloc de code où le thread en cours d'exécution peut bloquer pour le GC. La plupart des sites d'appels sont considérés comme des points de restauration.Il existe des invariants forts qui sont valables à chaque point de restauration, qui peuvent être ignorés dans les points de non-restauration. Le code Java compilé et le code C / C ++ peuvent être optimisés entre les points de restauration, mais moins entre les points de restauration. Le compilateur JIT émet une carte GC à chaque safepoint. Le code C / C ++ dans la machine virtuelle utilise des conventions stylisées basées sur des macros (par exemple, TRAPS) pour marquer les points de restauration potentiels.

En cours d'exécution avec les indicateurs mentionnés ci-dessus, j'obtiens cette sortie:

Application time: 0.9668750 seconds
Total time for which application threads were stopped: 0.0000747 seconds, Stopping threads took: 0.0000291 seconds
timeElapsed=1015
Application time: 1.0148568 seconds
Total time for which application threads were stopped: 0.0000556 seconds, Stopping threads took: 0.0000168 seconds
timeElapsed=1015
timeElapsed=1014
Application time: 2.0453971 seconds
Total time for which application threads were stopped: 10.7951187 seconds, Stopping threads took: 10.7950774 seconds
timeElapsed=11732
Application time: 1.0149263 seconds
Total time for which application threads were stopped: 0.0000644 seconds, Stopping threads took: 0.0000368 seconds
timeElapsed=1015

Notez le troisième événement STW:
Temps total d'arrêt: 10,7951187 secondes L'
arrêt des threads a pris: 10,7950774 secondes

JIT lui-même n'a pratiquement pas pris de temps, mais une fois que la JVM a décidé d'effectuer une compilation JIT, elle est entrée en mode STW, mais comme le code à compiler (la boucle infinie) n'a pas de site d'appel , aucun point de sécurité n'a jamais été atteint.

Le STW se termine lorsque JIT abandonne finalement l'attente et conclut que le code est dans une boucle infinie.

Andreas
la source
"Safepoint - Un point pendant l'exécution du programme où toutes les racines GC sont connues et tous les contenus des objets de tas sont cohérents" - Pourquoi cela ne serait-il pas vrai dans une boucle qui ne définit / lit que des variables de type valeur locales?
BlueRaja - Danny Pflughoeft
@ BlueRaja-DannyPflughoeft J'ai essayé de répondre à cette question dans ma réponse
vsminkov
5

Après avoir suivi les fils de commentaires et quelques tests par moi-même, je pense que la pause est causée par le compilateur JIT. Pourquoi le compilateur JIT prend si longtemps est au-delà de ma capacité à déboguer.

Cependant, puisque vous avez seulement demandé comment éviter cela, j'ai une solution:

Tirez votre boucle infinie dans une méthode où elle peut être exclue du compilateur JIT

public class TestBlockingThread {
    private static final Logger LOGGER = Logger.getLogger(TestBlockingThread.class.getName());

    public static final void main(String[] args) throws InterruptedException     {
        Runnable task = () -> {
            infLoop();
        };
        new Thread(new LogTimer()).start();
        Thread.sleep(2000);
        new Thread(task).start();
    }

    private static void infLoop()
    {
        int i = 0;
        while (true) {
            i++;
            if (i != 0) {
                boolean b = 1 % i == 0;
            }
        }
    }

Exécutez votre programme avec cet argument VM:

-XX: CompileCommand = exclude, PACKAGE.TestBlockingThread :: infLoop (remplacez PACKAGE par les informations de votre package)

Vous devriez recevoir un message comme celui-ci pour indiquer quand la méthode aurait été compilée par JIT:
### Excluant compile: static blocking.TestBlockingThread :: infLoop
vous remarquerez peut-être que j'ai mis la classe dans un package appelé blocking

Jeutnarg
la source
1
Le compilateur ne prend pas si longtemps, le problème est que le code n'atteint pas un point sûr car il n'y en a pas à l'intérieur de la boucle sauf quandi == 0
Peter Lawrey
@PeterLawrey mais pourquoi la fin de cycle en whileboucle n'est pas un point de sécurité?
vsminkov
@vsminkov Il semble qu'il y ait un point de restauration, if (i != 0) { ... } else { safepoint(); }mais c'est très rare. c'est à dire. si vous quittez / rompez la boucle, vous obtenez à peu près les mêmes horaires.
Peter Lawrey
@PeterLawrey après un peu d'enquête, j'ai trouvé qu'il était courant de faire un point de sécurité au saut arrière de la boucle. Je suis simplement curieux de savoir quelle est la différence dans ce cas particulier. peut-être que je suis naïf mais je ne vois aucune raison pour laquelle le saut en arrière n'est pas "sûr"
vsminkov
@vsminkov Je soupçonne que le JIT voit qu'un safepoint est dans la boucle, donc n'en ajoute pas un à la fin.
Peter Lawrey