Нитка Java, що виконує залишкову операцію в циклі, блокує всі інші потоки


123

Наступний фрагмент коду виконує два потоки, один - це простий таймер, що реєструє щосекунди, другий - нескінченний цикл, який виконує залишкову операцію:

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

Це дає такий результат:

[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

Я не розумію, чому нескінченна задача блокує всі інші потоки на 13,3 секунди. Я намагався змінити пріоритети потоку та інші налаштування, нічого не вийшло.

Якщо у вас є якісь пропозиції щодо виправлення цього питання (включаючи налаштування параметрів перемикання контексту ОС), будь ласка, повідомте мене про це.


8
@Marthin Не GC. Це JIT. Запускаючи з, -XX:+PrintCompilationя отримую наступне, коли закінчується тривала затримка: TestBlockingThread :: lambda $ 0 @ 2 (24 байти) КОМПЛІКУВАННЯ СКОРОГО: тривіальна нескінченна петля (повтор у різному рівні)
Андреас

4
Він відтворюється в моїй системі, єдиною зміною є те, що я замінив виклик журналу на System.out.println. Здається, проблема з планувальником, тому що якщо ви введете сон у 1 мс всередині циклу Runnable, тоді як (справжній) цикл пауза в іншому потоці проходить.
JJF

3
Не те, що я рекомендую, але якщо ви відключите використання JIT -Djava.compiler=NONE, це не станеться.
Андреас

3
Ви можете нібито відключити JIT для одного методу. Див. Вимкнути Java JIT для конкретного методу / класу?
Андреас

3
У цьому коді немає цілого поділу. Виправте свою назву та питання.
Маркіз Лорн

Відповіді:


94

Після всіх пояснень тут (завдяки Пітеру Лоурі ) ми виявили, що основним джерелом цієї паузи є те, що безпечна точка всередині циклу досягається досить рідко, тому потрібно тривати час, щоб зупинити всі потоки для заміни компільованого JIT коду.

Але я вирішив піти глибше і виявити, чому безпечна точка досягається рідко. Мені було дещо заплутаним, чому задній стрибок whileпетлі в цьому випадку не є "безпечним".

Тому я закликаю -XX:+PrintAssemblyу всій красі допомогти

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

Після деякого розслідування я виявив, що після третьої рекомпіляції лямбда C2компілятор повністю викинув опитування безпечної точки всередині циклу.

ОНОВЛЕННЯ

Під час етапу профілювання змінну iніколи не бачили рівним 0. Ось чому C2спекулятивно оптимізували цю гілку, щоб цикл перетворився на щось подібне

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

Зауважте, що спочатку нескінченна петля була перероблена на звичайний кінцевий цикл із лічильником! Через оптимізацію JIT для усунення опитувань безпечної точки в кінцевому підрахунку циклів, також не було проведено опитування безпечної точки в цьому циклі.

Через деякий час iзавернувся назад 0, і нечаста пастка була взята. Метод був деоптимізований і тривав виконання в інтерпретаторі. Під час перекомпіляції з новим знанням C2розпізнав нескінченний цикл і відмовився від компіляції. Решта методу перекладача переходили з належними безпечними точками.

Існує чудова обов'язкова прочитана публікація в блозі "Безпечні точки: значення, побічні ефекти та накладні витрати" від Nitsan Wakart, що висвітлює безпечні точки та цю проблему.

Як відомо, усунення Safepoint у дуже довгих певних петлях є проблемою. Клоп JDK-5014723(завдяки Володимиру Іванову ) вирішує цю проблему.

Рішення доступне, поки помилка остаточно не виправлена.

  1. Ви можете спробувати скористатися -XX:+UseCountedLoopSafepoints(це призведе до загального штрафу за продуктивність і може призвести до краху JVM JDK-8161147 ). Після використання C2компілятора продовжуйте зберігати безпечні точки на задньому стрибку, і початкова пауза повністю зникає.
  2. Можна явно відключити компіляцію проблемного методу за допомогою
    -XX:CompileCommand='exclude,binary/class/Name,methodName'

  3. Або ви можете переписати свій код, додавши безпечну точку вручну. Наприклад, Thread.yield()дзвінок в кінці циклу або навіть зміна int iна long i(спасибі, Nitsan Wakart ) також виправить паузу.


7
Це справжня відповідь на питання, як виправити .
Андреас

ПОПЕРЕДЖЕННЯ: Не використовуйте -XX:+UseCountedLoopSafepointsу виробництві, оскільки це може призвести до руйнування JVM . Найкращий шлях до цього часу - це розділити довгу петлю вручну на коротші.
apangin

@apangin aah. зрозумів! дякую :) так що тому c2видаляє безпечні точки! але ще одна річ, яку я не отримав - це те, що буде далі. наскільки я можу бачити, що після розкручування циклу (?) не залишилося безпечних точок, і схоже, що немає можливості зробити stw. значить, відбувається якийсь час очікування та відбувається деоптимізація?
всмінков

2
Мій попередній коментар не був точним. Тепер абсолютно зрозуміло, що відбувається. На етапі профілювання iніколи не дорівнює 0, тому цикл спекулятивно перетворюється на щось на зразок, наприклад, for (int i = osr_value; i != 0; i++) { if (1 % i == 0) uncommon_trap(); } uncommon_trap();звичайний кінцевий цикл, що рахується. Після iповернення до 0, приймається нечаста пастка, метод деоптимізується і переходить до інтерпретатора. Під час перекомпіляції з новими знаннями JIT розпізнає нескінченний цикл і відмовляється від компіляції. Решта методу виконується в перекладачі з належними безпечними точками.
apangin

1
Ви можете просто зробити ia long замість int, що зробить цикл "без рахунку" і вирішить проблему.
Nitsan Wakart

64

Коротше кажучи, цикл у вас немає безпечної точки всередині нього, за винятком випадків, коли i == 0досягнуто. Коли цей метод компілюється і запускає код, який потрібно замінити, йому потрібно довести всі потоки до безпечної точки, але це займе дуже багато часу, зафіксувавши не тільки потік, на якому працює код, але і всі потоки в JVM.

Я додав наступні параметри командного рядка.

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

Я також змінив код для використання плаваючої точки, яка, здається, займає більше часу.

boolean b = 1.0 / i == 0;

І те, що я бачу на виході, - це

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

Примітка: для заміни коду нитки повинні бути зупинені у безпечній точці. Однак тут виявляється, що така безпечна точка досягається дуже рідко (можливо, лише при i == 0зміні завдання на

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

Я бачу подібну затримку.

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

Додавши код до циклу обережно, ви отримаєте більш тривалу затримку.

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

отримує

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

Однак змініть код, щоб використовувати нативний метод, який завжди має безпечну точку (якщо він не є властивим)

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

відбитки

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

Примітка: додавання if (Thread.currentThread().isInterrupted()) { ... }до циклу додає безпечну точку.

Примітка. Це сталося на 16-ядерній машині, тому ресурсів процесора не бракує.


1
Так це помилка JVM, правда? Де "помилка" означає серйозну якість впровадження, а не порушення специфікації.
usr

1
@vsminkov, який може зупинити світ на кілька хвилин через відсутність безпечних точок, звучить так, що до нього слід ставитися як до помилок. Виконати час введення безпечних точок, щоб уникнути тривалого очікування.
Ву

1
@Voo, але, з іншого боку, збереження безпечних точок при кожному стрибку назад може коштувати чимало циклів процесора та призвести до помітного погіршення продуктивності всього додатка. але я з вами згоден у цьому конкретному випадку виглядає законним збереження
безпечної точки

9
@Voo добре ... Я завжди згадую цю картину, коли йдеться про оптимізацію продуктивності: D
vsminkov

1
.NET тут вставляє безпечні точки (але .NET має повільний код). Можливе рішення - відрізати петлю. Розділіть на дві петлі, внутрішню не перевірте на наявність партій з 1024 елементів, а зовнішня петля приводить у рух партії та безпечні точки. Зрізає накладні витрати на 1024x, менше на практиці.
usr

26

Знайшов відповідь, чому . Вони називаються безпечними точками і найбільш відомі як "Стоп-світ", що відбувається через ГК.

Дивіться цю статтю: Реєстрація пауз стоп-світу у JVM

Різні події можуть призвести до того, що JVM призупинить всі потоки програм. Такі паузи називаються паузами Stop-The-World (STW). Найбільш поширеною причиною виникнення паузи STW є збирання сміття (наприклад, в github), але різні дії JIT (приклад), упереджена скасування блокування (приклад), певні операції JVMTI та багато інших також вимагають припинення програми.

Точки , в яких потоки додатки можуть бути безпечно зупинені називають, здивування, safepoints . Цей термін також часто використовується для позначення всіх пауз STW.

Більш-менш поширеним є ввімкнення журналів GC. Однак це не фіксує інформацію про всі точки безпеки. Щоб отримати все, скористайтеся цими параметрами JVM:

-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime

Якщо вам цікаво, як іменування явно посилається на GC, не турбуйтеся - увімкнувши ці параметри, записуйте всі безпечні точки, а не лише паузи щодо збору сміття. Якщо ви запускаєте наступний приклад (джерело в github) із зазначеними вище прапорами.

Читаючи Глосарій термінів HotSpot , він визначає це:

безпечна точка

Точка під час виконання програми, в якій всі корені GC відомі і весь вміст об'єктів купи узгоджується. З глобальної точки зору, всі потоки повинні блокуватися в безпечній точці, перш ніж GC може запуститися. (Як особливий випадок, нитки з кодом JNI можуть продовжувати працювати, оскільки вони використовують лише ручки. Під час безпечної точки вони повинні блокувати замість завантаження вмісту ручки.) З локальної точки зору безпечна точка - це відмітна точка в блоці коду, де виконавчий потік може блокувати для GC. Більшість сайтів для дзвінків кваліфікуються як безпечні точки.Є сильні інваріанти, які відповідають дійсності в кожній безпечній точці, яку можна знехтувати в небезпечних точках. І компільований код Java, і код C / C ++ мають бути оптимізовані між безпечними точками, але менше - через безпечні точки. Компілятор JIT видає карту GC у кожній безпечній точці. Код C / C ++ у VM використовує стилізовані конвенції на основі макросу (наприклад, TRAPS) для позначення потенційних безпечних точок.

Запускаючи вищезгадані прапори, я отримую такий вихід:

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

Зверніть увагу на третю подію STW:
Загальний час зупинено: 10.7951187 секунд
Припинення потоків зайняло: 10.7950774 секунди

Сам JIT практично не займав часу, але як тільки JVM вирішив виконати компіляцію JIT, він перейшов у режим STW, однак, оскільки для коду для компіляції (нескінченний цикл) немає сайту виклику , жодна безпечна точка так і не була досягнута.

STW закінчується, коли JIT врешті-решт відмовляється від очікування і робить висновок, що код знаходиться у нескінченному циклі.


"Safepoint - точка під час виконання програми, в якій всі корені GC відомі та весь вміст об'єктів купи" - Чому це не буде правдою в циклі, який встановлює / зчитує лише локальні змінні типу?
BlueRaja - Danny Pflughoeft

@ BlueRaja-DannyPflughoeft Я намагався відповісти на це питання у своїй відповіді
vsminkov

5

Дотримуючись ниток коментарів і деяких тестувань самостійно, я вважаю, що паузу викликає компілятор JIT. Чому компілятор JIT займає такий довгий час, виходить за рамки моєї можливості налагодження.

Однак, оскільки ви лише запитували, як це запобігти, у мене є рішення:

Перетягніть свій нескінченний цикл у метод, коли його можна виключити з компілятора 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;
            }
        }
    }

Запустіть свою програму за допомогою цього аргументу VM:

-XX: CompileCommand = виключити, PACKAGE.TestBlockingThread :: infLoop (замініть ПАКЕТ на інформацію про ваш пакет)

Ви повинні отримати таке повідомлення, щоб вказати, коли метод був би компільований JIT:
### Виключення компіляції: статичне блокування.TestBlockingThread :: infLoop
Ви можете помітити, що я клав клас у пакет, який називається блокуючим


1
Компілятор не забирає так довго, проблема полягає в тому, що код не досягає безпечної точки, тому що всередині циклу немає жодного, крім випадків, колиi == 0
Пітер Лорі

@PeterLawrey, але чому кінець циклу в whileциклі не є безпечною точкою ?
всмінков

@vsminkov Здається, є безпечна точка, if (i != 0) { ... } else { safepoint(); }але це дуже рідко. тобто. якщо ви виходите / перериваєте цикл, ви отримаєте майже однакові таймінги.
Пітер Лорі

@PeterLawrey після невеликого розслідування я виявив, що це звичайна практика робити безпечну точку при стрибку назад. Мені просто цікаво, в чому різниця в цьому конкретному випадку. можливо, я наївний, але не бачу причини, чому стрибок назад не є "безпечним"
vsminkov

@vsminkov Я підозрюю, що JIT бачить безпечну точку в циклі, тому в кінці не додає.
Пітер Лорі
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.