В 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];
}
}
}
Мое предположение заключалось в том, что синхронизированное ключевое слово хуже из-за риска блокировки блокировки (потенциально вызывающих переключатели контекста потока и т.д.), хотя код короче.
Спасибо!
Ответы
Ответ 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);
}
}