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

В Java, какова производительность AtomicInteger compareAndSet() по сравнению с синхронизированным ключевым словом?

Я реализовал очередь экземпляров запросов FIFO (предварительно назначенные объекты запроса для скорости) и начал с использования "синхронизированного" ключевого слова в методе добавления. Метод был довольно коротким (проверьте, есть ли место в буфере фиксированного размера, а затем добавьте значение в массив). Используя visualVM, казалось, что поток блокировался чаще, чем мне нравилось ( "монитор", если быть точным). Поэтому я преобразовал код для использования значений AtomicInteger для таких вещей, как отслеживание текущего размера, а затем использование compareAndSet() в циклах (как AtomicInteger делает внутренне для таких методов, как incrementAndGet()). Теперь код выглядит довольно долго.

То, что мне было интересно, - это накладные расходы на производительность использования синхронизированного и более короткого кода по сравнению с более длинным кодом без синхронизированного ключевого слова (поэтому никогда не блокировать блокировку).

Вот старый метод get с синхронизированным ключевым словом:

public synchronized Request get()
{
    if (head == tail)
    {
        return null;
    }
    Request r = requests[head];
    head = (head + 1) % requests.length;
    return r;
}

Вот новый метод get без синхронизированного ключевого слова:

public Request get()
{
    while (true)
    {
        int current = size.get();
        if (current <= 0)
        {
            return null;
        }
        if (size.compareAndSet(current, current - 1))
        {
            break;
        }
    }

    while (true)
    {
        int current = head.get();
        int nextHead = (current + 1) % requests.length;
        if (head.compareAndSet(current, nextHead))
        {
            return requests[current];
        }
    }
}

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

Спасибо!

4b9b3361

Ответ 1

Мое предположение заключалось в том, что синхронизированное ключевое слово хуже из-за риска блокировки блокировки (потенциально вызывающих переключатели контекста потока и т.д.)

Да, в общем случае вы правы. Java Concurrency на практике обсуждает это в разделе 15.3.2:

[...] при высоком уровне конкуренции блокировки имеют тенденцию превосходить атомные переменные, но при более реалистичных уровнях конкуренции атомные переменные превосходят блокировки. Это связано с тем, что блокировка реагирует на конфликт, приостанавливая потоки, уменьшая нагрузку на ЦП и трафик синхронизации на шине общей памяти. (Это похоже на то, как блокирующие производители в дизайне производителя-потребителя уменьшают нагрузку на потребителей и тем самым позволяют им догнать.) С другой стороны, с атомными переменными управление конфликтами возвращается в вызывающий класс. Как и большинство алгоритмов на основе CAS, AtomicPseudoRandom реагирует на конфликты, пытаясь снова сразу, что обычно является правильным подходом, но в среде с высоким уровнем конкуренции просто вызывает больше конфликтов.

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

Реверсирование производительности между блокировками и атомами при разных уровнях конкуренции иллюстрирует сильные и слабые стороны каждого из них. С низким и средним уровнем конкуренции атомы предлагают лучшую масштабируемость; с высокой конкуренцией, замки предлагают лучшее предотвращение конкуренции. (Алгоритмы на основе CAS также превосходят блокировки на однопроцессорных системах, поскольку CAS всегда преуспевает в системе с одним процессором, за исключением маловероятного случая, когда поток выгружается в середине операции read-modify-write. )

(На рисунках, упомянутых в тексте, на рисунке 15.1 показано, что производительность AtomicInteger и ReentrantLock более или менее одинакова, когда конфликт высок, а на рисунке 15.2 показано, что при умеренном утверждении первое превосходит последнее по коэффициенту 2-3.)

Обновление: по неблокирующим алгоритмам

Как отмечали другие, неблокирующие алгоритмы, хотя и потенциально более быстрые, являются более сложными, поэтому сложнее получить право. Подсказка из раздела 15.4 JCiA:

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

Неблокирующие алгоритмы значительно сложнее, чем их эквиваленты на основе блокировки. Ключом к созданию неблокирующих алгоритмов является выяснение того, как ограничить объем атомных изменений одной переменной, сохраняя при этом согласованность данных. В связанных классах коллекций, таких как очереди, вы иногда можете уйти с выражением преобразований состояний в виде изменений в отдельных ссылках и используя AtomicReference для представления каждой ссылки, которая должна быть обновлена ​​атомарно.

Ответ 2

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

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

Ответ 3

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

Да, синхронизированный в некоторых условиях может быть медленнее, чем атомная операция, но сравнить исходные и замещающие методы. Первый действительно ясен и прост в обслуживании, последний, безусловно, определенно более сложный. Из-за этого могут быть очень тонкие ошибки concurrency, которые вы не найдете во время первоначального тестирования. Я уже вижу одну проблему, size и head могут действительно выйти из синхронизации, потому что, хотя каждая из этих операций является атомарной, комбинация не является, и иногда это может привести к несогласованному состоянию.

Итак, мой совет:

  • Начать простой
  • Профиль
  • Если производительность достаточно хорошая, оставьте простую реализацию как есть
  • Если вам нужно улучшить производительность, тогда начните получать умные (возможно, сначала используя более специализированную блокировку) и TEST, TEST, TEST

Ответ 4

Здесь код для блокировки ожидания занятости.

public class BusyWaitLock
{
    private static final boolean LOCK_VALUE = true;
    private static final boolean UNLOCK_VALUE = false;
    private final static Logger log = LoggerFactory.getLogger(BusyWaitLock.class);

    /**
     * @author Rod Moten
     *
     */
    public class BusyWaitLockException extends RuntimeException
    {

        /**
         * 
         */
        private static final long serialVersionUID = 1L;

        /**
         * @param message
         */
        public BusyWaitLockException(String message)
        {
            super(message);
        }



    }

    private AtomicBoolean lock = new AtomicBoolean(UNLOCK_VALUE);
    private final long maximumWaitTime ; 

    /**
     * Create a busy wait lock with that uses the default wait time of two minutes.
     */
    public BusyWaitLock()
    {
        this(1000 * 60 * 2); // default is two minutes)
    }

    /**
     * Create a busy wait lock with that uses the given value as the maximum wait time.
     * @param maximumWaitTime - a positive value that represents the maximum number of milliseconds that a thread will busy wait.
     */
    public BusyWaitLock(long maximumWaitTime)
    {
        if (maximumWaitTime < 1)
            throw new IllegalArgumentException (" Max wait time of " + maximumWaitTime + " is too low. It must be at least 1 millisecond.");
        this.maximumWaitTime = maximumWaitTime;
    }

    /**
     * 
     */
    public void lock ()
    {
        long startTime = System.currentTimeMillis();
        long lastLogTime = startTime;
        int logMessageCount = 0;
        while (lock.compareAndSet(UNLOCK_VALUE, LOCK_VALUE)) {
            long waitTime = System.currentTimeMillis() - startTime;
            if (waitTime - lastLogTime > 5000) {
                log.debug("Waiting for lock. Log message # {}", logMessageCount++);
                lastLogTime = waitTime;
            }
            if (waitTime > maximumWaitTime) {
                log.warn("Wait time of {} exceed maximum wait time of {}", waitTime, maximumWaitTime);
                throw new BusyWaitLockException ("Exceeded maximum wait time of " + maximumWaitTime + " ms.");
            }
        }
    }

    public void unlock ()
    {
        lock.set(UNLOCK_VALUE);
    }
}