Как дрожание JVM может быть вызвано циклом for без выделения объектов?

Я провел микротестирование следующего кода и заметил кое-что интересное, на что я надеюсь, кто-нибудь сможет пролить больше света. Это приводит к ситуации, когда кажется, что цикл for может продолжать работать быстро, блокируя другие потоки в JVM. Если это правда, то я хотел бы понять, почему, если это не так, то любое понимание того, что я могу упустить, будет оценено по достоинству.

Чтобы создать ситуацию, позвольте мне рассказать вам о тесте, который я запускаю, и его результатах.

Код довольно прост, перебирает каждый элемент массива, суммируя его содержимое. Повторить targetCount раз.

public class UncontendedByteArrayReadBM extends Benchmark {

private int arraySize;
private byte[] array;

public UncontendedByteArrayReadBM( int arraySize ) {
    super( "array reads" );

    this.arraySize = arraySize;
}

@Override
public void setUp() {
    super.setUp();

    array = new byte[arraySize];
}

@Override
public void tearDown() {
    array = null;
}

@Override
public BenchmarkResult invoke( int targetCount ) {
    long sum = 0;
    for ( int i=0; i<targetCount; i++ ) {
        for ( int j=0; j<arraySize; j++ ) {
            sum += array[j];
        }
    }

    return new BenchmarkResult( ((long)targetCount)*arraySize, "uncontended byte array reads", sum );
}

}

На моем 4-ядерном ноутбуке Intel Sandy Bridged/i7 с тактовой частотой 2 ГГц под управлением Java 6 (Oracle JVM) на OSX. Этот код неоднократно запускается для

2,626852686364034 несогласованных байтовых массива чтений/нс [totalTestRun=3806,837 мс]

(Я отбраковал повторные прогоны, используемые для разогрева JVM)

Этот результат кажется мне разумным.

Это стало интересным, когда я начал измерять джиттер JVM. Для этого я запускаю поток фонового демона, который спит в течение 1 мс, а затем выясняю, насколько дольше 1 мс он действительно спал. И я изменил отчет, чтобы распечатать максимальный джиттер для каждого повторного запуска теста.

2,6109858273078306 несогласованных байтовых массивов чтений/нс [maxJitter=0,411 мс totalTestRun=3829,971 мс]

Чтобы получить представление о «нормальном» джиттере для моей среды, перед запуском фактических тестовых прогонов я отслеживаю джиттер без какой-либо работы, и показания, подобные приведенным ниже, являются типичными (все в мс). Таким образом, джиттер в 0,411 мс является нормальным и не столь интересным.

getMaxJitterMillis() = 0.599
getMaxJitterMillis() = 0.37
getMaxJitterMillis() = 0.352

Я включил код того, как я измерил джиттер, в конце этого вопроса.

Однако интересная часть, и да, это происходит во время периода «прогрева JVM» и, следовательно, не является «нормальным», но я хотел бы более подробно понять следующее:

2.4519521584902644 uncontended byte array reads/ns  [maxJitter=2561.222ms totalTestRun=4078.383ms]

Обратите внимание, что дрожание составляет более 2,5 секунд. Обычно я бы отнес это к GC. Однако я запустил System.gc() перед запуском теста, И -XX: + PrintGCDetails не показывает GC в это время. На самом деле, во время любого из тестовых прогонов не происходит GC, поскольку в этом тесте суммирования предварительно выделенных байтов не происходит выделения объектов. Это также происходит каждый раз, когда я запускаю новый тест, и поэтому я не подозреваю, что это вмешательство какого-то другого процесса, происходящего случайным образом.

Мое любопытство резко возросло, потому что, когда я заметил, что хотя джиттер очень высок, общее время выполнения и количество считываний элементов массива в наносекунду остались более или менее неизменными. Итак, вот ситуация, когда поток сильно отстает на 4-ядерной машине, в то время как сам рабочий поток не отстает, и GC не происходит.

Продолжая расследование, я изучил, что делает компилятор Hotspot, и нашел следующее через -XX:+PrintCompilation:

2632   2%      com.mosaic.benchmark.datastructures.array.UncontendedByteArrayReadBM::invoke @ 14 (65 bytes)
6709   2%     made not entrant  com.mosaic.benchmark.datastructures.array.UncontendedByteArrayReadBM::invoke @ -2 (65 bytes)

Задержка между выводом этих двух строк составляла около 2,5 секунд. Именно тогда, когда оптимизированный код метода, содержащего большие циклы for, помечен как не входной.

Насколько я понимаю, Hotspot работает в фоновом потоке, и когда он готов подкачать новую версию кода, он ждет, пока этот код, который уже запущен, достигнет безопасной точки, а затем подменяется. В случае большой цикл for, который находится в конце каждого тела цикла (который, возможно, был развернут). Я бы не ожидал задержки в 2,5 с, если только этот обмен не должен был выполнить событие остановки мира через JVM. Это происходит при деоптимизации предыдущего скомпилированного кода?

Итак, мой первый вопрос к внутренним экспертам JVM: на правильном ли я пути? Может ли задержка в 2,5 секунды быть связана с тем, что метод помечен как «не входной»; и если да, то почему это так сильно влияет на другие потоки? Если это не может быть причиной, то любые идеи о том, что еще исследовать, были бы замечательными.

(для полноты вот код, который я использую для измерения джиттера)

private static class MeasureJitter extends Thread {
    private AtomicLong maxJitterWitnessedNS = new AtomicLong(0);

    public MeasureJitter() {
        setDaemon( true );
    }

    public void reset() {
        maxJitterWitnessedNS.set( 0 );
    }

    public double getMaxJitterMillis() {
        return maxJitterWitnessedNS.get()/1000000.0;
    }

    public void printMaxJitterMillis() {
        System.out.println( "getMaxJitterMillis() = " + getMaxJitterMillis() );
    }

    @Override
    public void run() {
        super.run();

        long preSleepNS = System.nanoTime();
        while( true ) {
            try {
                Thread.sleep( 1 );
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            long wakeupNS = System.nanoTime();
            long jitterNS = Math.max(0, wakeupNS - (preSleepNS+1000000));

            long max = Math.max( maxJitterWitnessedNS.get(), jitterNS );
            maxJitterWitnessedNS.lazySet( max );

            preSleepNS = wakeupNS;
        }
    }
}

person Chris K    schedule 11.04.2013    source источник


Ответы (2)


Потребовалось немного покопаться, чтобы найти дымящийся пистолет, но уроки были ценными; особенно о том, как доказать и изолировать причину. Поэтому я подумал, что было бы хорошо задокументировать их здесь.

JVM действительно ждала, чтобы выполнить событие Stop The World. У Алексея Рагозина есть очень хорошая запись в блоге на эту тему по адресу http://blog.ragozin.info/2012/10/safepoints-in-hotspot-jvm.html и именно этот пост навел меня на правильный путь. Он указывает, что безопасные точки находятся на границах методов JNI и вызовах методов Java. Таким образом, цикл for, который у меня здесь, не имеет безопасных точек.

Чтобы понять остановку мировых событий в Java, используйте следующие флаги JVM: -XX:+PrintGCApplicationStoppedTime -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1

Первый выводит общую продолжительность события остановки мира и не ограничивается только GC. В моем случае здесь распечатано:

Total time for which application threads were stopped: 2.5880809 seconds

Что доказало, что у меня была проблема с потоками, ожидающими достижения безопасной точки. Следующие два аргумента выводят, почему JVM хотела дождаться достижения глобальной безопасной точки.

         vmop                    [threads: total initially_running wait_to_block]    [time: spin block sync cleanup vmop] page_trap_count
4.144: EnableBiasedLocking              [      10          1              1    ]      [  2678     0  2678     0     0    ]  0   
Total time for which application threads were stopped: 2.6788891 seconds

Таким образом, это говорит о том, что JVM ждала 2678 мс, пытаясь включить предвзятую блокировку. Почему это событие Stop the World? К счастью, Мартин Томпсон уже сталкивался с этой проблемой в прошлом и задокументировал ее здесь. Оказывается, у Oracle JVM довольно много конфликтов между потоками во время запуска, в это время смещенная блокировка имеет очень высокую стоимость, поэтому она задерживает включение оптимизации на четыре секунды. Итак, что здесь произошло, так это то, что мой микротест длился более четырех секунд, а затем в его цикле не было безопасных точек. Поэтому, когда JVM пыталась включить предвзятую блокировку, ей приходилось ждать.

Решения-кандидаты, которые все сработали для меня, были:

  1. -XX:-UseBiasedLocking (выключить смещенную блокировку)
  2. -XX:BiasedLockingStartupDelay=0 (немедленно включить смещенную блокировку)
  3. Измените цикл, чтобы в нем была безопасная точка (например, вызов метода, который не оптимизируется или не встраивается)
person Chris K    schedule 14.04.2013

Есть много причин дрожания

  • сон очень ненадежен на миллисекундном уровне.
  • переключатели контекста
  • прерывает
  • кеш отсутствует из-за запущенных других программ

Даже если вы заняты ожиданием, привяжите поток к изолированному процессору, например. с isocpus и переместите все прерывания, которые вы можете, с этого процессора, вы все равно можете увидеть небольшое количество джиттера. Все, что вы можете сделать, это уменьшить его.

Кстати: jHiccup делает именно то, что вы делаете для измерения джиттера вашей системы.

person Peter Lawrey    schedule 11.04.2013
comment
Согласен, и этот совет приносил мне пользу в течение последних пятнадцати лет :) Но теперь у меня есть зуд усилить свое понимание. Я знаю, что измерение времени в Java открыто для довольно больших проблем детализации часов, а также задержек, возможно, до 16 или 32 мс для System.currentTimeMillis(), поэтому обычное дрожание 0,4 мс не вызвало у меня интереса. 2,5 секунды - это много по сравнению с этим. - person Chris K; 11.04.2013
comment
В настоящее время я думаю, что переключение контекста и прерывания от внешних событий будут более случайными, чем я вижу. Эта задержка в 2,5 секунды работает как часы. И не будут ли промахи уровня кэш-памяти ЦП больше влиять на пропускную способность чтения массива, чем на дрожание в другом потоке? Если, возможно, планировщик не мог назначить поток джиттера тому же ядру, что и тот, который выполняет цикл for (который не очень часто дает результат), и ему еще предстоит переместить его на другое ядро. хммм, как это проверить? :) - person Chris K; 11.04.2013
comment
Кстати, если у вас более двух сокетов и старая версия Windows, вы увидите, что время безотказной работы процессора на двух сокетах отличается. Это может объяснить фиксированную кажущуюся задержку в 2,5 секунды. Я предлагаю вам также распечатать отрицательные задержки. Я ожидаю увидеть задержку от -2,4 до -2,5 между вашими 2,5-секундными задержками. - person Peter Lawrey; 11.04.2013
comment
В более новых ОС это будет исправлять разницу во времени безотказной работы между сокетами, но в старых версиях Linux и Windows это не так. - person Peter Lawrey; 11.04.2013
comment
Это интересный факт для машин с несколькими процессорами. Это работает на односокетном (четырехъядерном) ноутбуке под управлением OSx 10.8.3. Но просто чтобы убедиться, что я добавил дополнительный AtomicLong для захвата задержек джиттера Math.min (и убедился, что вызов max(0,jitter) был удален) и обнаружил, что он всегда проходит как 0 мс. Я думаю, что эти выходные мы проведем за чтением кода OpenJDK :) - person Chris K; 11.04.2013
comment
Распространенным заблуждением является то, что JVM контролирует работу многопоточности. В общем, он просто оборачивает системные вызовы ОС, которые выполняют всю реальную работу. - person Peter Lawrey; 11.04.2013