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

Играя с многопоточностью, я мог наблюдать некоторые неожиданные, но серьезные проблемы с производительностью, связанные с AtomicLong (и классами, использующими его, такими как java.util.Random), которым я в настоящее время не могу объяснить. Однако я создал минималистичный пример, который в основном состоит из двух классов: класса «Контейнер», который хранит ссылку на переменную, и класса «DemoThread», который работает с экземпляром «Контейнера» во время выполнения потока. Обратите внимание, что ссылки на «Container» и volatile long являются частными и никогда не разделяются между потоками (я знаю, что нет необходимости использовать 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 OS Резюме:

Версия Java: 1.6.0_24
Версия Java Class: 50.0
Поставщик VM: Sun Microsystems Inc.
Версия VM: 19.1-b02-334
Имя виртуальной машины: Java HotSpot (TM) 64-битный сервер VM
Название ОС: Mac OS X
ОС Arch: x86_64
Версия ОС: 10.6.5
Процессоры / ядер: 8

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

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


Обзор Windows:

Java версия: 1.6.0_24
Версия Java Class: 50.0
Поставщик VM: Sun Microsystems Inc.
Версия VM: 19.1-b02
Имя виртуальной машины: Java HotSpot (TM) 64-битный сервер VM
Название ОС: Windows 7
ОС Arch: amd64
Версия ОС: 6.1
Процессоры / ядра: 4

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

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


Linux Summary:

Java версия: 1.6.0_20
Версия Java Class: 50.0
Поставщик VM: Sun Microsystems Inc.
Версия ВМ: 19.0-b09
Имя виртуальной машины: 64-битный сервер OpenJDK VM
Название ОС: Linux
ОС Arch: amd64
Версия ОС: 2.6.32-28-generic
Процессоры / Ядра: 4

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

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


Сведения о Mac OS (энергозависимые):

Тест 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 (), несколькоИногда будут происходить те же эффекты: поток будет выполняться быстро, если вы создадите объект в конструкторе потока, но медленный, если вы создадите его в методе run ().Я считаю, что проблемы с производительностью, описанные в Java Random Slowdowns в Mac OS более года назад, связаны с этим эффектом, но я понятия не имею, почему это так - кроме того, что я уверен, чтоэто не должно быть так, поскольку это будет означать, что всегда опасно создавать новый объект в методе выполнения потока, если только вы не знаете, что в графе объектов не будут задействованы переменные.Профилирование не помогает, так как в этом случае проблема исчезает (то же самое наблюдение, что и в Случайные замедления Java в Mac OS продолжение ), и это также не происходит на одноядерном ПК - такЯ предполагаю, что это своего рода проблема синхронизации потоков ... однако странно то, что на самом деле синхронизировать нечего, поскольку все переменные являются локальными для потоков.

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

Спасибо,

Стефан

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

}

Ответы [ 4 ]

16 голосов
/ 12 апреля 2011

Вероятно, что две изменчивые переменные 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

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

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

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

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

К счастью, A и B могут напрямую общаться о новых значениях, не проходя через основную память.Тем не менее, это требует дополнительного времени.

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

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

7 голосов
/ 12 апреля 2011

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

РЕДАКТИРОВАТЬ: Как уже отмечалось, есть проблемы с самим тестом, такие как печать во время работы таймера. Кроме того, обычно хорошей идеей является «разогреть» JIT перед началом синхронизации, иначе вы измеряете время, которое не будет значительным в обычном длительном процессе.

0 голосов
/ 12 апреля 2011

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

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

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

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

0 голосов
/ 12 апреля 2011

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

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

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

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

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

Способ проверить этот ответ:

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

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

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

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

...