Рубрики
Без рубрики

Оптимизация производительности Hashmap

Автор оригинала: Michał Dąbrowski.

1. введение

HashMap – это мощная структура данных, которая имеет широкое применение, особенно когда требуется быстрое время поиска. И все же, если мы не будем обращать внимания на детали, это может стать неоптимальным.

В этом уроке мы рассмотрим, как сделать HashMap как можно быстрее.

2. Узкое место HashMap

Оптимистическое постоянное время извлечения элемента HashMap (|/O(1) ) исходит из силы хеширования. Для каждого элемента HashMap вычисляет хэш-код и помещает элемент в корзину, связанную с этим хэш-кодом. Поскольку неравные объекты могут иметь одинаковые хэш-коды (явление, называемое столкновением хэш-кодов), ведра могут увеличиваться в размерах.

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

В худшем случае, когда мы помещаем все в одно ведро, наша HashMap понижается до связанного списка. Следовательно, вместо O(1) времени поиска мы получаем очень неудовлетворительное O(n) .

3. Дерево вместо LinkedList

Начиная с Java 8, one optimization встроена в HashMap : Когда ведра становятся слишком большими, они преобразуются в деревья, а не в связанные списки. Это приближает пессимистическое время O(n) к O(log(n)) , что намного лучше. Для того чтобы это сработало, ключи HashMap должны реализовать интерфейс Comparable .

Это хорошее и автоматическое решение, но оно не идеально. O(log(n)) все еще хуже, чем желаемое постоянное время, а преобразование и хранение деревьев требует дополнительной мощности и памяти.

4. Лучшая реализация хэш-кода

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

4.1. Измерение качества хэш-кода

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

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

Точно так же плохой метод hashCode будет иметь очень несбалансированное распределение. В самом худшем случае он всегда будет возвращать одно и то же число.

4.2. Хэш-код объекта по умолчанию

В общем, мы не должны использовать метод default Object’s | hashCode , потому что мы не хотим использовать идентификатор объекта в методе equals . Однако в том очень маловероятном сценарии, в котором мы действительно хотим использовать идентификатор объекта для ключей в HashMap , функция по умолчанию hashCode будет работать нормально. В противном случае нам понадобится пользовательская реализация.

4.3. Пользовательский хэш-код

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

Допустим, идентичность нашего объекта основана исключительно на его целочисленном id . Затем мы можем просто использовать этот id в качестве хэш-функции:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;

    MemberWithId that = (MemberWithId) o;

    return id.equals(that.id);
}

@Override
public int hashCode() {
    return id;
}

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

Ситуация усложнится, если у нас будет больше полей, которые мы должны учитывать. Допустим, мы хотим основывать равенство как на id , так и на name :

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;

    MemberWithIdAndName that = (MemberWithIdAndName) o;

    if (!id.equals(that.id)) return false;
    return name != null ? name.equals(that.name) : that.name == null;
}

Теперь нам нужно как-то объединить хэши id и name .

Во-первых, мы получим хэш id такой же, как и раньше. Затем мы умножим его на какое-нибудь тщательно выбранное число и добавим хэш name :

@Override
public int hashCode() {
    int result = id.hashCode();
    result = PRIME * result + (name != null ? name.hashCode() : 0);
    return result;
}

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

31 * i == (i << 5) - i

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

524287 * i == i << 19 - i

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

4.4. Объекты Служебного Класса

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

@Override
public int hashCode() {
    return Objects.hash(id, name);
}

Под капотом он использует именно тот алгоритм, который описан ранее с номером 31 как множитель.

4.5. Другие Хэш-Функции

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

Если по какой-то причине нам действительно нужно качество и мы не очень заботимся о скорости, мы можем взглянуть на класс Hashing из библиотеки Guava :

@Override
public int hashCode() {
    HashFunction hashFunction = Hashing.murmur3_32();
    return hashFunction.newHasher()
      .putInt(id)
      .putString(name, Charsets.UTF_8)
      .hash().hashCode();
}

Важно выбрать 32-битную функцию, потому что мы все равно не можем хранить более длинные хэши.

5. Заключение

Современная Java HashMap -это мощная и хорошо оптимизированная структура данных. Однако его производительность может быть ухудшена плохо разработанным методом hashCode . В этом уроке мы рассмотрели возможные способы сделать хэширование быстрым и эффективным.

Как всегда, примеры кода для этой статьи доступны на GitHub .