Der Java-Thread, der die Restoperation in einer Schleife ausführt, blockiert alle anderen Threads

123

Das folgende Codefragment führt zwei Threads aus, einer ist eine einfache Timer-Protokollierung jede Sekunde, der zweite ist eine Endlosschleife, die eine Restoperation ausführt:

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);
            }
        }
    }
}

Dies ergibt das folgende Ergebnis:

[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

Ich verstehe nicht, warum die unendliche Aufgabe alle anderen Threads für 13,3 Sekunden blockiert. Ich habe versucht, Thread-Prioritäten und andere Einstellungen zu ändern, nichts hat funktioniert.

Wenn Sie Vorschläge zur Behebung dieses Problems haben (einschließlich der Optimierung der Einstellungen für die Betriebssystemumschaltung), lassen Sie es mich bitte wissen.

kms333
quelle
8
@ Martin nicht GC. Es ist JIT. Laufen mit -XX:+PrintCompilationmir folgenden zu der Zeit bekommt die verlängerten Verzögerungs Ende: TestBlockingThread :: lambda $ 0 @ 2 (24 Byte) KOMPILIERT SKIPPED: trivial Endlosschleife (Neuversuch bei verschiedenem Tiere)
Andreas
4
Es wird auf meinem System reproduziert. Die einzige Änderung besteht darin, dass ich den Protokollaufruf durch System.out.println ersetzt habe. Scheint ein Scheduler-Problem zu sein, denn wenn Sie einen 1-ms-Ruhezustand innerhalb der while (true) -Schleife des Runnable einführen, verschwindet die Pause im anderen Thread.
JJF
3
Nicht, dass ich es empfehle, aber wenn Sie JIT mit deaktivieren-Djava.compiler=NONE , wird es nicht passieren.
Andreas
3
Sie können JIT angeblich für eine einzelne Methode deaktivieren. Siehe Java JIT deaktivieren für eine bestimmte Methode / Klasse?
Andreas
3
In diesem Code gibt es keine Ganzzahldivision. Bitte korrigieren Sie Ihren Titel und Ihre Frage.
Marquis von Lorne

Antworten:

94

Nach all den Erklärungen hier (dank Peter Lawrey ) haben wir festgestellt, dass die Hauptursache für diese Pause darin besteht, dass der Sicherheitspunkt innerhalb der Schleife eher selten erreicht wird. Daher dauert es lange, bis alle Threads für das Ersetzen von JIT-kompiliertem Code gestoppt sind.

Aber ich beschloss, tiefer zu gehen und herauszufinden, warum der Sicherheitspunkt selten erreicht wird. Ich fand es etwas verwirrend, warum der Rücksprung der whileSchleife in diesem Fall nicht "sicher" ist.

Also rufe ich -XX:+PrintAssemblyin all seiner Pracht, um zu helfen

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

Nach einigen Nachforschungen stellte ich fest, dass nach der dritten Neukompilierung des Lambda- C2Compilers Safepoint-Umfragen innerhalb der Schleife vollständig weggeworfen wurden.

AKTUALISIEREN

Während der Profilierungsphase wurde die Variable inie gleich 0 gesehen. Deshalb wurde C2dieser Zweig spekulativ weg optimiert, sodass die Schleife in so etwas wie transformiert wurde

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

Beachten Sie, dass die ursprüngliche Endlosschleife mit einem Zähler in eine reguläre Endlosschleife umgeformt wurde! Aufgrund der JIT-Optimierung zur Eliminierung von Safepoint-Abfragen in Schleifen mit endlicher Zählung gab es auch in dieser Schleife keine Safepoint-Abfrage.

Nach einiger Zeit wieder ieingewickelt 0, und die ungewöhnliche Falle wurde genommen. Die Methode wurde deoptimiert und im Interpreter weiter ausgeführt. Während der Neukompilierung mit neuem Wissen C2erkannte die Endlosschleife und gab die Kompilierung auf. Der Rest der Methode wurde im Dolmetscher mit geeigneten Sicherheitspunkten fortgesetzt.

Es gibt einen großartigen Blog-Beitrag "Sicherheitspunkte: Bedeutung, Nebenwirkungen und Gemeinkosten" von Nitsan Wakart, der die Sicherheitspunkte und dieses spezielle Problem behandelt.

Die Eliminierung sicherer Punkte in sehr lang gezählten Schleifen ist bekanntermaßen ein Problem. Der Fehler JDK-5014723(dank Vladimir Ivanov ) behebt dieses Problem.

Die Problemumgehung ist verfügbar, bis der Fehler endgültig behoben ist.

  1. Sie können es versuchen -XX:+UseCountedLoopSafepoints(dies führt zu einer allgemeinen Leistungsminderung und kann zu einem Absturz der JVM führen JDK-8161147 ). Nach der Verwendung des C2Compilers bleiben die Sicherheitspunkte bei den Rücksprüngen erhalten, und die ursprüngliche Pause verschwindet vollständig.
  2. Sie können die Kompilierung problematischer Methoden mithilfe von explizit deaktivieren
    -XX:CompileCommand='exclude,binary/class/Name,methodName'

  3. Oder Sie können Ihren Code neu schreiben, indem Sie den Sicherheitspunkt manuell hinzufügen. Wenn Sie beispielsweise Thread.yield()am Ende des Zyklus anrufen oder sogar int izu long i(danke, Nitsan Wakart ) wechseln , wird auch die Pause behoben .

vsminkov
quelle
7
Dies ist die wahre Antwort auf die Frage, wie das Problem behoben werden kann .
Andreas
WARNUNG: Nicht -XX:+UseCountedLoopSafepointsin der Produktion verwenden, da dies zu einem Absturz der JVM führen kann . Die bisher beste Problemumgehung besteht darin, die lange Schleife manuell in kürzere zu unterteilen.
Apangin
@apangin aah. Ich habs! danke :) deshalb werden c2sicherungspunkte entfernt! Aber eine weitere Sache, die ich nicht verstanden habe, ist, was als nächstes passiert. Soweit ich sehen kann, gibt es nach dem Abrollen der Schleife (?) keine Sicherheitspunkte mehr und es sieht so aus, als gäbe es keine Möglichkeit, stw zu machen. Es kommt also zu einer Art Timeout und es findet eine De-Optimierung statt?
Vsminkov
2
Mein vorheriger Kommentar war nicht korrekt. Jetzt ist völlig klar, was passiert. In der Profilierungsphase iist niemals 0, daher wird die Schleife spekulativ in for (int i = osr_value; i != 0; i++) { if (1 % i == 0) uncommon_trap(); } uncommon_trap();eine reguläre Schleife mit endlicher Zählung umgewandelt. Sobald der iWraps auf 0 zurückgesetzt wurde, wird die ungewöhnliche Falle genommen, die Methode wird deoptimiert und im Interpreter fortgesetzt. Während der Neukompilierung mit dem neuen Wissen erkennt JIT die Endlosschleife und gibt die Kompilierung auf. Der Rest der Methode wird im Interpreter mit geeigneten Sicherheitspunkten ausgeführt.
Apangin
1
Sie könnten ia long anstelle eines int machen, was die Schleife "ungezählt" machen und das Problem lösen würde.
Nitsan Wakart
64

Kurz gesagt, die Schleife, die Sie haben, hat keinen sicheren Punkt, außer wenn sie i == 0erreicht ist. Wenn diese Methode kompiliert wird und den zu ersetzenden Code auslöst, müssen alle Threads an einen sicheren Punkt gebracht werden. Dies dauert jedoch sehr lange und blockiert nicht nur den Thread, in dem der Code ausgeführt wird, sondern alle Threads in der JVM.

Ich habe die folgenden Befehlszeilenoptionen hinzugefügt.

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

Ich habe den Code auch so geändert, dass Gleitkommazahlen verwendet werden, die anscheinend länger dauern.

boolean b = 1.0 / i == 0;

Und was ich in der Ausgabe sehe, ist

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

Hinweis: Damit Code ersetzt werden kann, müssen Threads an einem sicheren Punkt gestoppt werden. Hier scheint es jedoch so zu sein, dass ein solcher sicherer Punkt sehr selten erreicht wird (möglicherweise nur beim i == 0Ändern der Aufgabe in

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

Ich sehe eine ähnliche Verzögerung.

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

Wenn Sie der Schleife vorsichtig Code hinzufügen, erhalten Sie eine längere Verzögerung.

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

bekommt

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

Ändern Sie den Code jedoch so, dass eine native Methode verwendet wird, die immer einen sicheren Punkt hat (wenn es sich nicht um eine intrinsische Methode handelt).

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

druckt

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

Hinweis: Durch Hinzufügen if (Thread.currentThread().isInterrupted()) { ... }zu einer Schleife wird ein sicherer Punkt hinzugefügt .

Hinweis: Dies geschah auf einem 16-Kern-Computer, sodass es nicht an CPU-Ressourcen mangelt.

Peter Lawrey
quelle
1
Es ist also ein JVM-Fehler, oder? Wobei "Fehler" ein schwerwiegendes Problem mit der Qualität der Implementierung und keine Verletzung der Spezifikation bedeutet.
usr
1
@vsminkov in der Lage zu sein, die Welt wegen fehlender Sicherheitspunkte für einige Minuten anzuhalten, klingt so, als sollte es als Fehler behandelt werden. Die Laufzeit ist dafür verantwortlich, Sicherheitspunkte einzuführen, um lange Wartezeiten zu vermeiden.
Voo
1
@Voo, aber andererseits kann das Beibehalten von Sicherheitspunkten bei jedem Rücksprung viele CPU-Zyklen kosten und zu einer spürbaren Leistungsverschlechterung der gesamten Anwendung führen. aber ich stimme dir zu. in diesem speziellen Fall scheint es legitim zu sein, den Sicherheitspunkt zu behalten
vsminkov
9
@ Voo gut ... Ich erinnere mich immer an dieses Bild, wenn es um Leistungsoptimierungen geht: D
vsminkov
1
.NET fügt hier Sicherheitspunkte ein (.NET hat jedoch langsam generierten Code). Eine mögliche Lösung besteht darin, die Schleife zu zerlegen. In zwei Schleifen aufteilen, die innere nicht auf Stapel von 1024 Elementen prüfen lassen und die äußere Schleife Stapel und Sicherheitspunkte antreibt. Reduziert den Overhead konzeptionell um das 1024-fache, in der Praxis weniger.
usr
26

Fand die Antwort warum . Sie werden als Sicherheitspunkte bezeichnet und sind am besten als Stop-The-World bekannt, das aufgrund von GC auftritt.

Siehe diese Artikel: Protokollierung von Stop-the-World-Pausen in JVM

Verschiedene Ereignisse können dazu führen, dass die JVM alle Anwendungsthreads anhält. Solche Pausen werden als Stop-The-World (STW) -Pausen bezeichnet. Die häufigste Ursache für das Auslösen einer STW-Pause ist die Speicherbereinigung (Beispiel in Github). Unterschiedliche JIT-Aktionen (Beispiel), voreingenommene Sperrung (Beispiel), bestimmte JVMTI-Vorgänge und vieles mehr erfordern jedoch auch das Stoppen der Anwendung.

Die Punkte, an denen die Anwendungsthreads sicher gestoppt werden können, werden als Überraschungspunkte bezeichnet . Dieser Begriff wird auch häufig verwendet, um alle STW-Pausen zu bezeichnen.

Es ist mehr oder weniger üblich, dass GC-Protokolle aktiviert sind. Dies erfasst jedoch nicht alle Sicherheitspunkte. Verwenden Sie die folgenden JVM-Optionen, um alles zu erhalten:

-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime

Wenn Sie sich über die Benennung wundern, die sich explizit auf GC bezieht, seien Sie nicht beunruhigt. Wenn Sie diese Optionen aktivieren, werden alle Sicherheitspunkte protokolliert, nicht nur die Speicherbereinigungspausen. Wenn Sie ein folgendes Beispiel (Quelle in Github) mit den oben angegebenen Flags ausführen.

Beim Lesen des HotSpot-Glossars der Begriffe wird Folgendes definiert:

Sicherheitspunkt

Ein Punkt während der Programmausführung, an dem alle GC-Roots bekannt sind und alle Heap-Objektinhalte konsistent sind. Aus globaler Sicht müssen alle Threads an einem sicheren Punkt blockiert werden, bevor der GC ausgeführt werden kann. (Als Sonderfall können Threads, auf denen JNI-Code ausgeführt wird, weiterhin ausgeführt werden, da sie nur Handles verwenden. Während eines Sicherheitspunkts müssen sie den Inhalt des Handles blockieren, anstatt ihn zu laden.) Aus lokaler Sicht ist ein Sicherheitspunkt ein definierter Punkt in einem Codeblock, in dem der ausführende Thread für den GC blockieren kann. Die meisten Anrufstellen gelten als Sicherheitspunkte.Es gibt starke Invarianten, die an jedem Sicherheitspunkt zutreffen und an Nicht-Sicherheitspunkten ignoriert werden können. Sowohl kompilierter Java-Code als auch C / C ++ - Code können zwischen Sicherheitspunkten optimiert werden, jedoch weniger zwischen Sicherheitspunkten. Der JIT-Compiler gibt an jedem Sicherheitspunkt eine GC-Karte aus. C / C ++ - Code in der VM verwendet stilisierte makrobasierte Konventionen (z. B. TRAPS), um potenzielle Sicherheitspunkte zu markieren.

Wenn ich mit den oben genannten Flags laufe, erhalte ich folgende Ausgabe:

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

Beachten Sie das dritte STW-Ereignis:
Gesamtzeit gestoppt: 10.7951187 Sekunden Das
Stoppen von Threads dauerte: 10.7950774 Sekunden

JIT selbst so gut wie keine Zeit in Anspruch nahm, aber sobald die JVM eine JIT - Kompilierung durchzuführen entschieden hatte, ging es STW - Modus, aber da der Code (die Endlosschleife) kompiliert werden keine hat Aufrufort wurde kein Sicherungspunkt jemals erreicht.

Das STW endet, wenn JIT schließlich das Warten aufgibt und feststellt, dass sich der Code in einer Endlosschleife befindet.

Andreas
quelle
"Safepoint - Ein Punkt während der Programmausführung, an dem alle GC-Roots bekannt sind und alle Heap-Objektinhalte konsistent sind" - Warum sollte dies nicht in einer Schleife zutreffen, die nur lokale Variablen vom Werttyp setzt / liest?
BlueRaja - Danny Pflughoeft
@ BlueRaja-DannyPflughoeft Ich habe versucht, diese Frage in meiner Antwort zu beantworten
vsminkov
5

Nachdem ich den Kommentarthreads und einigen Tests selbst gefolgt bin, glaube ich, dass die Pause vom JIT-Compiler verursacht wird. Warum der JIT-Compiler so lange dauert, kann ich nicht debuggen.

Da Sie jedoch nur gefragt haben, wie Sie dies verhindern können, habe ich eine Lösung:

Ziehen Sie Ihre Endlosschleife in eine Methode, mit der sie vom JIT-Compiler ausgeschlossen werden kann

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;
            }
        }
    }

Führen Sie Ihr Programm mit diesem VM-Argument aus:

-XX: CompileCommand = exclude, PACKAGE.TestBlockingThread :: infLoop (ersetzen Sie PACKAGE durch Ihre Paketinformationen)

Sie sollten eine Meldung wie diese erhalten, um anzugeben, wann die Methode JIT-kompiliert worden wäre:
### Ohne Kompilierung: statische Blockierung. TestBlockingThread :: infLoop
Möglicherweise stellen Sie fest, dass ich die Klasse in ein Paket namens Blockierung eingefügt habe

Jeutnarg
quelle
1
Der Compiler dauert nicht so lange, das Problem ist, dass der Code keinen sicheren Punkt erreicht, da sich keiner in der Schleife befindet, außer wenni == 0
Peter Lawrey
@PeterLawrey, aber warum ist das Ende des Zyklus in der whileSchleife kein sicherer Punkt?
Vsminkov
@vsminkov Es scheint, dass es einen Sicherheitspunkt gibt, if (i != 0) { ... } else { safepoint(); }aber dies ist sehr selten. dh. Wenn Sie die Schleife verlassen / unterbrechen, erhalten Sie fast die gleichen Timings.
Peter Lawrey
@PeterLawrey Nach einigem Nachforschen stellte ich fest, dass es üblich ist, einen Sicherheitspunkt beim Rücksprung der Schleife zu machen. Ich bin nur neugierig, was der Unterschied in diesem speziellen Fall ist. Vielleicht bin ich naiv, aber ich sehe keinen Grund, warum der Rücksprung nicht "sicher" ist
vsminkov
@vsminkov Ich vermute, dass die JIT sieht, dass sich ein Sicherheitspunkt in der Schleife befindet, also füge am Ende keinen hinzu.
Peter Lawrey