Статьи

Улучшение производительности блокировки в Java

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

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

Блокировка не зло, блокировка раздора

Всякий раз, когда вы сталкиваетесь с проблемой производительности с многопоточным кодом, есть вероятность, что вы начнете обвинять блокировки. В конце концов, общеизвестно, что блокировки медленные и ограничивают масштабируемость. Так что, если вы обладаете этими «знаниями» и начинаете оптимизировать код и избавляться от блокировок, есть шанс, что вы в конечном итоге представите неприятные ошибки параллелизма, которые появятся позже.

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

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

Защитите данные не код

Быстрый способ достижения безопасности потоков — блокировка доступа ко всему методу. Например, посмотрите на следующий пример, иллюстрирующий наивную попытку построить сервер онлайн-покера:

class GameServer {
public Map<<String, List<Player>> tables = new HashMap<String, List<Player>>();




public synchronized void join(Player player, Table table) {
if (player.getAccountBalance() > table.getLimit()) {
List<Player> tablePlayers = tables.get(table.getId());
if (tablePlayers.size() < 9) {
tablePlayers.add(player);
}
}
}
public synchronized void leave(Player player, Table table) {/*body skipped for brevity*/}
public synchronized void createTable() {/*body skipped for brevity*/}
public synchronized void destroyTable(Table table) {/*body skipped for brevity*/}
}

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

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

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

class GameServer {
public Map<String, List<Player>> tables = new HashMap<String, List<Player>>();




public void join(Player player, Table table) {
synchronized (tables) {
if (player.getAccountBalance() > table.getLimit()) {
List<Player> tablePlayers = tables.get(table.getId());
if (tablePlayers.size() < 9) {
tablePlayers.add(player);
}
}
}
}
public void leave(Player player, Table table) {/* body skipped for brevity */}
public void createTable() {/* body skipped for brevity */}
public void destroyTable(Table table) {/* body skipped for brevity */}
}

То, что первоначально казалось незначительным изменением, теперь влияет на поведение всего класса. Всякий раз, когда игроки присоединялись к столам, ранее синхронизированные методы блокировались на  экземпляре GameServer ( это ) и представляли конфликтные события игрокам, пытающимся одновременно покинуть ()  столы. Перемещение блокировки из сигнатуры метода в тело метода откладывает блокировку и снижает вероятность конфликта.

Уменьшить область блокировки

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

public class GameServer {
public Map<String, List<Player>> tables = new HashMap<String, List<Player>>();




public void join(Player player, Table table) {
if (player.getAccountBalance() > table.getLimit()) {
synchronized (tables) {
List<Player> tablePlayers = tables.get(table.getId());
if (tablePlayers.size() < 9) {
tablePlayers.add(player);
}
}
}
}
//other methods skipped for brevity
}

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

Разделите свои замки

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

Для этого существует простой способ ввести индивидуальные блокировки для каждой таблицы, например, в следующем примере:

public class GameServer {
public Map<String, List<Player>> tables = new HashMap<String, List<Player>>();




public void join(Player player, Table table) {
if (player.getAccountBalance() > table.getLimit()) {
List<Player> tablePlayers = tables.get(table.getId());
synchronized (tablePlayers) {
if (tablePlayers.size() < 9) {
tablePlayers.add(player);
}
}
}
}
//other methods skipped for brevity
}

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

Используйте параллельные структуры данных

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

public class GameServer {
public Map<String, List<Player>> tables = new ConcurrentHashMap<String, List<Player>>();




public synchronized void join(Player player, Table table) {/*Method body skipped for brevity*/}
public synchronized void leave(Player player, Table table) {/*Method body skipped for brevity*/}




public synchronized void createTable() {
Table table = new Table();
tables.put(table.getId(), table);
}




public synchronized void destroyTable(Table table) {
tables.remove(table.getId());
}
}

Синхронизация в  методах join ()  и  exit ()  по-прежнему работает так же, как и в нашем предыдущем примере, поскольку нам необходимо защитить целостность отдельных таблиц. Так что никакой помощи от  ConcurrentHashMap  в этом отношении нет. Но так как мы также создаем новые таблицы и уничтожаем таблицы в  методах createTable ()  и  destroyTable () , все эти операции с  ConcurrentHashMap  полностью параллельны, что позволяет параллельно увеличивать или уменьшать количество таблиц.

Другие советы и хитрости

  • Уменьшить видимость замка. В приведенном выше примере блокировки объявляются  общедоступными  и, таким образом, видны миру, поэтому есть вероятность, что кто-то еще испортит вашу работу, также заблокировав ваши тщательно отобранные мониторы.
  • Проверьте  java.util.concurrent.locks,  чтобы увидеть, улучшит ли какая-либо из реализованных там стратегий блокировки.
  • Используйте атомарные операции. Простое увеличение счетчика, которое мы фактически проводим в приведенном выше примере, на самом деле не требует блокировки. Замена Integer в отслеживании счетчиков на AtomicInteger  лучше всего подойдет для этого примера.

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