Подтвердить что ты не робот

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

Когда вы играете многопоточно, я могу наблюдать некоторые неожиданные, но серьезные проблемы с производительностью, связанные с AtomicLong (и его классы, например java.util.Random), для которых в настоящее время у меня нет объяснений. Тем не менее, я создал минималистский пример, который в основном состоит из двух классов: класса "Контейнер" , который содержит ссылку на переменную volatile и класс DemoThread, который работает с экземпляром "Контейнер" во время выполнения потока. Обратите внимание, что ссылки на "Контейнер" и долговечность являются частными и никогда не разделяются между потоками (я знаю, что здесь нет необходимости использовать volatile, это просто для демонстрационных целей) - таким образом, несколько экземпляров "DemoThread" должны работать отлично параллельно на многопроцессорной машине, но по какой-то причине они этого не делают (полный пример находится в нижней части этого сообщения).

private static class Container  {

    private volatile long value;

    public long getValue() {
        return value;
    }

    public final void set(long newValue) {
        value = newValue;
    }
}

private static class DemoThread extends Thread {

    private Container variable;

    public void prepare() {
        this.variable = new Container();
    }

    public void run() {
        for(int j = 0; j < 10000000; j++) {
            variable.set(variable.getValue() + System.nanoTime());
        }
    }
}

Во время моего теста я неоднократно создаю 4 DemoThreads, которые затем запускаются и соединяются. Единственная разница в каждом цикле - это время, когда вызывается вызов "prepare()" (что, очевидно, требуется для запуска потока, поскольку в противном случае это приведет к исключению NullPointerException):

DemoThread[] threads = new DemoThread[numberOfThreads];
    for(int j = 0; j < 100; j++) {
        boolean prepareAfterConstructor = j % 2 == 0;
        for(int i = 0; i < threads.length; i++) {
            threads[i] = new DemoThread();
            if(prepareAfterConstructor) threads[i].prepare();
        }

        for(int i = 0; i < threads.length; i++) {
            if(!prepareAfterConstructor) threads[i].prepare();
            threads[i].start();
        }
        joinThreads(threads);
    }

По какой-то причине, если prepare() выполняется непосредственно перед запуском потока, это займет в два раза больше времени для завершения, и даже без ключевого слова "volatile" различия в производительности были значительными, по крайней мере, на двух из машины и ОС, я тестировал код. Вот краткое резюме:


Краткое описание ОС Mac:

Версия Java: 1.6.0_24
Версия Java класса: 50,0
VM Vendor: Sun Microsystems Inc.
Версия VM: 19.1-b02-334
Имя виртуальной машины: виртуальная виртуальная машина Java HotSpot (TM)

Название ОС: Mac OS X
OS Arch: x86_64
Версия ОС: 10.6.5
Процессоры/ядра: 8

С ключевым словом volatile:
Конечные результаты:
31979 мс. когда prepare() вызывается после создания экземпляра.
96482 мс. когда prepare() вызывается перед выполнением.

Без ключевого слова volatile:
Конечные результаты:
26009 мс. когда prepare() вызывается после создания экземпляра.
35196 мс. когда prepare() вызывается перед выполнением.


Сводка Windows:

Версия Java: 1.6.0_24
Версия Java класса: 50,0
VM Vendor: Sun Microsystems Inc.
Версия VM: 19.1-b02
Имя виртуальной машины: виртуальная виртуальная машина Java HotSpot (TM)

Название ОС: Windows 7
OS Arch: amd64
Версия ОС: 6.1
Процессоры/ядра: 4

С ключевым словом volatile:
Конечные результаты:
18120 мс. когда prepare() вызывается после создания экземпляра.
36089 мс. когда prepare() вызывается перед выполнением.

Без ключевого слова volatile:
Конечные результаты:
10115 мс. когда prepare() вызывается после создания экземпляра.
10039 мс. когда prepare() вызывается перед выполнением.


Резюме Linux:

Версия Java: 1.6.0_20
Версия Java класса: 50,0
VM Vendor: Sun Microsystems Inc.
Версия VM: 19.0-b09
Имя виртуальной машины: 64-разрядная серверная платформа OpenJDK

Название ОС: Linux
OS Arch: amd64
Версия ОС: 2.6.32-28-generic
Процессоры/ядра: 4

С ключевым словом volatile:
Конечные результаты:
45848 мс. когда prepare() вызывается после создания экземпляра.
110754 мс. когда prepare() вызывается перед выполнением.

Без ключевого слова volatile:
Конечные результаты:
37862 мс. когда prepare() вызывается после создания экземпляра.
39357 мс. когда prepare() вызывается перед выполнением.


Сведения о Mac OS (volatile):

Тестирование 1, 4 потока, установка переменной в цикле создания
Поток-2 завершен через 653 мс.
Поток-3 завершен через 653 мс.
Поток-4 завершен через 653 мс.
Поток-5 завершен через 653 мс.
Общее время: 654 мс.

Тест 2, 4 потока, настройка переменной в стартовом цикле
Поток-7 завершен после 1588 мс.
Поток-6 завершен после 1589 мс.
Поток-8 завершен после 1593 мс.
Поток-9 завершен после 1593 мс.
Общее время: 1594 мс.

Тест 3, 4 потока, настройка переменной в цикле создания
Поток-10 завершен после 648 мс.
Поток-12 завершен после 648 мс.
Поток-13 завершен после 648 мс.
Поток-11 завершен после 648 мс.
Общее время: 648 мс.

Тест 4, 4 потока, настройка переменной в стартовом цикле
Поток-17 завершен после 1353 мс.
Поток-16 завершен после 1957 мс.
Поток-14 завершен после 2170 мс.
Поток-15 завершен после 2169 мс.
Общее время: 2172 мс.

(и так далее, иногда один или два потока в "медленном" цикле заканчиваются так, как ожидалось, но чаще всего они этого не делают).

Данный пример выглядит теоретически, поскольку он бесполезен, а "volatile" здесь не нужен, однако, если вы используете "java.util.Random" -Instance вместо "Container'-Class" и вызовите, например, nextInt() несколько раз, будут возникать те же эффекты: поток будет выполняться быстро, если вы создадите объект в конструкторе Thread, но медленно, если вы создадите его в методе run(). Я считаю, что проблемы производительности, описанные в Java Random Slowdowns на Mac OS более года назад, связаны с этим эффектом, но я понятия не имею, почему это так, как это - Кроме того, я уверен, что это не должно быть так, поскольку это будет означать, что всегда опасно создавать новый объект в run-методе потока, если вы не знаете, что во время графа объектов. Профилирование не помогает, так как проблема исчезает в этом случае (такое же наблюдение, как в Java Random Slowdowns на Mac OS cont'd), и это также не происходит одноядерный ПК - поэтому я бы предположил, что это проблема синхронизации потоков... однако странно, что на самом деле ничего не синхронизировать, так как все переменные являются локально-потоковыми.

В самом деле, ожидая намеков - и на всякий случай, если вы хотите подтвердить или фальсифицировать проблему, см. ниже тестовый пример.

Спасибо,

Stephan

public class UnexpectedPerformanceIssue {

private static class Container  {

    // Remove the volatile keyword, and the problem disappears (on windows)
    // or gets smaller (on mac os)
    private volatile long value;

    public long getValue() {
        return value;
    }

    public final void set(long newValue) {
        value = newValue;
    }
}

private static class DemoThread extends Thread {

    private Container variable;

    public void prepare() {
        this.variable = new Container();
    }

    @Override
    public void run() {
        long start = System.nanoTime();
        for(int j = 0; j < 10000000; j++) {
            variable.set(variable.getValue() + System.nanoTime());
        }
        long end = System.nanoTime();
        System.out.println(this.getName() + " completed after "
                +  ((end - start)/1000000) + " ms.");
    }
}

public static void main(String[] args) {
    System.out.println("Java Version: " + System.getProperty("java.version"));
    System.out.println("Java Class Version: " + System.getProperty("java.class.version"));

    System.out.println("VM Vendor: " + System.getProperty("java.vm.specification.vendor"));
    System.out.println("VM Version: " + System.getProperty("java.vm.version"));
    System.out.println("VM Name: " + System.getProperty("java.vm.name"));

    System.out.println("OS Name: " + System.getProperty("os.name"));
    System.out.println("OS Arch: " + System.getProperty("os.arch"));
    System.out.println("OS Version: " + System.getProperty("os.version"));
    System.out.println("Processors/Cores: " + Runtime.getRuntime().availableProcessors());

    System.out.println();
    int numberOfThreads = 4;

    System.out.println("\nReference Test (single thread):");
    DemoThread t = new DemoThread();
    t.prepare();
    t.run();

    DemoThread[] threads = new DemoThread[numberOfThreads];
    long createTime = 0, startTime = 0;
    for(int j = 0; j < 100; j++) {
        boolean prepareAfterConstructor = j % 2 == 0;
        long overallStart = System.nanoTime();
        if(prepareAfterConstructor) {
            System.out.println("\nTest " + (j+1) + ", " + numberOfThreads + " threads, setting variable in creation loop");             
        } else {
            System.out.println("\nTest " + (j+1) + ", " + numberOfThreads + " threads, setting variable in start loop");
        }

        for(int i = 0; i < threads.length; i++) {
            threads[i] = new DemoThread();
            // Either call DemoThread.prepare() here (in odd loops)...
            if(prepareAfterConstructor) threads[i].prepare();
        }

        for(int i = 0; i < threads.length; i++) {
            // or here (in even loops). Should make no difference, but does!
            if(!prepareAfterConstructor) threads[i].prepare();
            threads[i].start();
        }
        joinThreads(threads);
        long overallEnd = System.nanoTime();
        long overallTime = (overallEnd - overallStart);
        if(prepareAfterConstructor) {
            createTime += overallTime;
        } else {
            startTime += overallTime;
        }
        System.out.println("Overall time: " + (overallTime)/1000000 + " ms.");
    }
    System.out.println("Final results:");
    System.out.println(createTime/1000000 + " ms. when prepare() was called after instantiation.");
    System.out.println(startTime/1000000 + " ms. when prepare() was called before execution.");
}

private static void joinThreads(Thread[] threads) {
    for(int i = 0; i < threads.length; i++) {
        try {
            threads[i].join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

}

4b9b3361

Ответ 1

Вероятно, что две изменчивые переменные a и b слишком близки друг к другу, они попадают в одну и ту же строку кэша; хотя CPU a только считывает/записывает переменную a, а CPU b только считывает/записывает переменную b, они все еще связаны друг с другом через одну и ту же строку кэша. Такие проблемы называются ложными.

В вашем примере у нас есть две схемы распределения:

new Thread                               new Thread
new Container               vs           new Thread
new Thread                               ....
new Container                            new Container
....                                     new Container

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

Кэш-память процессора не работает с отдельными словами; вместо этого они занимаются линиями кэша. Линия кэша представляет собой непрерывный блок памяти, например 64 соседних байта. Обычно это хорошо - если процессор обратился к ячейке, очень вероятно, что он будет обращаться к соседним ячейкам тоже. За исключением вашего примера, это предположение не только недействительно, но и вредно.

Предположим, что a и b попадают в одну строку кэша L. Когда CPU a обновляет a, он уведомляет другие CPU, что L является грязным. Поскольку B кэширует L, потому что он работает с b, b, должен отбросить его кешированный L. Поэтому в следующий раз b нужно читать b, он должен перезагрузить L, что дорого.

Если b должен получить доступ к основной памяти для перезагрузки, что является чрезвычайно дорогостоящим, оно обычно на 100 раз медленнее.

К счастью, a и b могут напрямую связываться с новыми значениями без прохождения основной памяти. Тем не менее требуется дополнительное время.

Чтобы проверить эту теорию, вы можете добавить 128 байт в Container, так что две переменные volatile из двух Container не будут попадать в одну и ту же строку кэша; то вы должны заметить, что две схемы занимают примерно одно и то же время для выполнения.

Изучение Lession: обычно процессоры предполагают, что связанные переменные связаны. Если мы хотим независимых переменных, мы лучше размещаем их далеко друг от друга.

Ответ 2

Хорошо, вы пишете переменную volatile, так что я подозреваю, что это заставляет барьер памяти - отменить некоторую оптимизацию, которая в противном случае может быть достигнута. JVM не знает, что это конкретное поле не будет наблюдаться в другом потоке.

EDIT: Как уже отмечалось, есть проблемы с самим эталоном, например, печать во время работы таймера. Кроме того, обычно рекомендуется "разогреть" JIT до начала отсчета времени - иначе вы измеряете время, которое не было бы значительным в нормальном продолжительном процессе.

Ответ 3

Я не эксперт в области Java, но я прочитал ваш вопрос и нашел его увлекательным. Если бы мне пришлось угадать, я думаю, что вы обнаружили:

  • НЕ имеет никакого отношения к созданию свойства volitale. Однако из ваших данных, когда свойство получает экземпляр, влияет на то, как дорого читать/писать.

  • Имеет отношение к поиску ссылки на свойство volitale во время выполнения. То есть мне было бы интересно увидеть, как задержка растет с большим количеством потоков, которые чаще всего работают. Является ли количество вызовов свойства volitale причиной задержки или самого добавления или написания нового значения.

Я бы предположил, что: вероятно, есть оптимизация JVM, которая пытается быстро создать экземпляр свойства, а позже, если есть время, изменить свойство в памяти, чтобы его легче было читать и писать. Возможно, есть (1) быстро создаваемая очередь для чтения/записи для свойств волатиля и (2) труднодоступная, но быстро вызываемая очередь, а JVM начинается с (1), а затем изменяет свойство volitale к (2).

Возможно, если вы подготовите() перед вызовом метода run(), JVM не имеет достаточного количества бесплатных циклов для оптимизации от (1) до (2).

Чтобы проверить этот ответ, выполните следующие действия:

prepare(), sleep(), run() и посмотреть, не получится ли у вас такая же задержка. Если сон - единственное, что вызывает оптимизацию, то это может означать, что JVM нуждается в циклах для оптимизации свойства volitale

ИЛИ (немного более рискованно)...

подготовьте() и запустите() потоки, расположенные позже в середине цикла, либо в паузу(), либо в sleep() или как-то прекратите доступ к свойству таким образом, чтобы JVM могла попытаться переместить его ( 2).

Мне было бы интересно узнать, что вы узнали. Интересный вопрос.

Ответ 4

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

Я не очень много знаю о проблемах с памятью в многопроцессорных средах, но если бы мне пришлось угадать, возможно, во втором случае распределение контейнеров с большей вероятностью будет распределено на одной и той же странице памяти, и, возможно, процессоры замедляются из-за конкуренции за одну и ту же страницу памяти?

[edit] Следуя этой мысли, мне было бы интересно узнать, что произойдет, если вы не попытаетесь написать обратно к переменной и только прочитать ее в методе Thread Thread. Я бы ожидал, что разница в таймингах исчезнет.

[edit2] См. непобедимый ответ; он объясняет это намного лучше, чем я мог