Автор оригинала: 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 .