Существуют десятки достойных реализаций хеш-таблиц без блокировок. Обычно эти структуры данных вместо использования простых блокировок используют операции на основе CAS, чтобы быть свободными от блокировки. с этим повествованием может показаться, что я собираюсь использовать этот пост, чтобы создать аргумент для структур данных без блокировки. это не так, довольно удивительно. напротив, здесь мы будем говорить о старых простых замках.
Теперь, когда все готовы, давайте реализуем параллельную версию Map
с этим простым контрактом:
Поскольку это будет поточно-ориентированная структура данных, мы должны получить некоторые блокировки и впоследствии снять их при установке, получении или удалении элементов:
Также мы должны инициализировать реализацию с заранее определенным количеством сегментов:
Один размер подходит всем?
Что происходит, когда количество хранимых элементов намного больше, чем количество сегментов? Даже при наличии самых универсальных хеш-функций производительность нашей хеш-таблицы значительно снизится. Чтобы предотвратить это, нам нужно добавить больше сегментов при необходимости:
С помощью этих абстрактных методов шаблона, вот как мы можем поместить новую пару ключ-значение в нашу карту:
Чтобы найти корзину для нового элемента, мы полагаемся на hashcode
ключ. Также мы должны рассчитать hashcode
модуль количества сегментов, чтобы избежать выхода за пределы!
Остальные части довольно просты: мы получаем блокировку для новой записи и добавляем запись только в том случае, если она еще не существует. В противном случае мы бы обновили текущую запись. В конце операции, если мы пришли к такому выводу, что нам нужно добавить больше блоков, то мы сделаем это, чтобы сохранить баланс хеш-таблицы. get
И remove
методы могут быть реализованы как легко.
Давайте заполним пробелы, реализовав все эти абстрактные методы!
Один замок, чтобы управлять ими всеми
Самый простой способ реализовать поточно-ориентированную версию этой структуры данных - это использовать только одну блокировку для всех своих операций:
Текущие реализации получения и выпуска полностью независимы от записи карты. Как и было обещано, мы используем только один ReentrantLock
для синхронизации. Этот подход известен как грубая синхронизация, поскольку мы используем только одну блокировку для обеспечения эксклюзивного доступа ко всей хеш-таблице.
Кроме того, мы должны изменить размер хеш-таблицы, чтобы поддерживать ее постоянный доступ и время модификации. Для этого мы можем использовать разные эвристики. Например, когда средний размер корзины превышает определенное число:
Чтобы добавить больше сегментов, мы должны скопировать текущую хеш-таблицу и создать новую таблицу, скажем, в два раза больше. Затем добавьте все записи из старой таблицы в новую:
Обратите внимание, что мы также должны получить и снять ту же блокировку для операции изменения размера.
Последовательное узкое место
Предположим, есть три одновременных запроса для помещения чего-либо в первый сегмент, получения чего-либо из третьего набора и удаления чего-либо из шестого набора:
В идеале мы ожидаем, что параллельная структура данных с высокой степенью масштабируемости будет обслуживать такие запросы с минимально возможной координацией. Однако вот что происходит в реальном мире:
Поскольку мы используем только одну блокировку для синхронизации, параллельные запросы будут заблокированы позади первой, которая получила блокировку. Когда первый снимает блокировку, второй получает ее и через некоторое время снимает ее.
Это явление известно как «Последовательное узкое место» (что-то вроде главы блокирования линий), и мы должны смягчить этот эффект в параллельных средах.
Соединенные полосы замков
Одним из способов улучшить нашу текущую грубую реализацию является использование набора блокировок вместо одной блокировки. Таким образом, каждая блокировка будет отвечать за синхронизацию одного или нескольких сегментов. Это более точная синхронизация по сравнению с нашим текущим подходом:
Здесь мы разделяем хеш-таблицу на несколько частей и синхронизируем каждую часть независимо. Таким образом мы уменьшаем конкуренцию за каждую блокировку и, следовательно, улучшаем пропускную способность. Эта идея также называется Lock Striping, потому что мы синхронизируем разные части (т.е. полосы) независимо:
Нам нужен метод, чтобы найти соответствующую блокировку для каждой записи (т. Е. Корзины):
Затем мы можем получить и снять блокировку для операции с определенной записью следующим образом:
Процедура изменения размера почти такая же, как и раньше. Единственное отличие состоит в том, что мы должны получить все блокировки до изменения размера и освободить их после изменения размера.
Мы ожидаем, что мелкозернистый подход превзойдет крупнозернистый, давайте измерим его!
Момент истины
Чтобы сравнить эти два подхода, мы собираемся использовать следующую рабочую нагрузку в обеих реализациях:
Каждый раз мы ставим случайный ключ, получаем случайный ключ и удаляем случайный ключ. Каждая рабочая нагрузка будет выполнять эти три операции 100 раз. При добавлении JMH в смесь каждая рабочая нагрузка будет выполняться тысячи раз:
В целом реализация может обрабатывать в среднем 8547 операций в секунду:
С другой стороны, мелкозернистая реализация может обрабатывать в среднем до 13 855 операций в секунду.
Рекурсивные сплит-упорядоченные списки
В своем документе Ори Шалев и Нир Шавит предложили способ реализации хеш-таблицы без блокировки. Они использовали способ упорядочения элементов в связанном списке, чтобы их можно было несколько раз «разделить» с помощью одной операции сравнения и замены. В любом случае, они использовали совершенно другой подход для реализации этой параллельной структуры данных. Настоятельно рекомендуется проверить и прочитать эту статью.
Также исходные коды этой статьи доступны на Github .