HashMap – одна из наиболее часто используемых структур данных в Java, и она известна своей эффективности. Данные в HashMap сохраняются в виде пар ключ-значение.
В этой статье я вас познакомим с HashMap в Java. Мы рассмотрим обычные операции с HashMap и затем более внутренние аспекты ее работы. Вы поймете, как работает哈希-функция и процесс вычисления индексов. Наконец, мы посмотрим на временные сложности операций и пощелкаем о поведении в многопоточной среде.
Что такое HashMap в Java?
HashMap реализует интерфейс Map, который является частью Java collection framework. Он основан на концепции Hashing.
Хэширование – это техника, которая преобразует входной арбитражный размер в выходной фиксированного размера с использованием хэш-функции. Generated output called the hash code and is represented by an integer value in Java. Hash codes are used for efficient lookup and storage operations in a HashMap.
Общие операции
Как мы уже говорили, данные в HashMap сохраняются в виде пар ключ-значение. Ключ является уникальным идентификатором, и каждый ключ связан с значением.
Below are some common operations supported by a HashMap. Let’s understand what these methods do with some simple code examples:
Вставка
-
This method inserts a new key-value pair to the HashMap.
-
Порядок вставки ключей-значений не сохраняется.
-
При вставке, если ключ уже существует, существующее значение заменяется новым значением, переданным.
-
Вы можете вставить только один null ключ в
HashMap
, но можете быть много null значений.
Signature метода для этой операции дана ниже, затем идет пример:
public V put(K key, V value)
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
В приведенном выше коде, у нас есть пример HashMap, где мы добавляем ключ типа String и значение типа Integer.
Поиск:
-
Возвращает значение, связанное с данным ключом.
-
Возвращает
null
, если ключ не существует вHashMap
.
Signature метода для этой операции дана ниже, затем идет пример:
public V get(Object key)
Integer value = map.get("apple"); // возвращает 1
В приведенном выше коде мы извлекаем значение, связанное с ключом apple
.
其他常见的操作包括:
-
Удаляет пару ключ-значение для указанного ключа. Возвращает
null
, если ключ не найден. -
containsKey
: Проверяет, присутствует ли указанный ключ вHashMap
. -
containsValue
: Проверяет, присутствует ли указанное значение вHashMap
.
Внутренняя структура HashMap
Внутри HashMap
используется массив бакетов или контейнеров. Каждый бакет представляет собой связанный список типа Node
, который используется для представления пары ключ-значение HashMap
.
static class Node<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next;
Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
Выше показана структура класса Node
, который используется для хранения пар ключ-значение HashMap
.
Класс Node
содержит следующие поля:
-
hash
: Ссылается наhashCode
ключа. -
key
: Ссылается на ключ пары ключ-значение. -
value
: Ссылается на значение, связанное с ключом. -
next
: Служит ссылкой на следующий узел.
Класс HashMap
по своей сути основан на реализации таблицы хэшей, и его производительность зависит от двух ключевых параметров: начальной вместимости и коэффициента загрузки. Оригинальные документы Javadoc класса таблицы хэшей определяют эти два параметра следующим образом:
-
Вместимость – это количество контейнеров в таблице хэшей, а начальная вместимость просто это вместимость таблицы хэшей в момент ее создания.
-
Коэффициент загрузки – это мера, показывающая, насколько заполнена таблица хэшей может стать до автоматического увеличения ее вместимости.
Давайте теперь попробуем понять, как работают базовые операции put
и get
в HashMap
.
Функция Хэша
В процессе вставки (put
) пары ключ-значение, HashMap
сначала вычисляет hash-код ключа. Hash-функция затем вычисляет для ключа整数. Классы могут использовать метод hashCode
класса Object
или переопределить этот метод и предоставить свое собственное реализацию. (Прочитайте о контракте hash code здесь). Hash-код затем имплицитно объединен с его верхними 16 битами (h >>> 16) для достижения более равномерного распределения.
XOR является bitwise операцией, сравнивая два бита, результируя в 1, если биты разные и 0, если они одинаковые. В этом контексте выполнение bitwise XOR операции между hash-кодом и его верхними 16 битами (полученными с использованием operator unsigned right shift >>>
) помогает смешать биты, приводя к более равномерному распределению hash-кода.
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
Вверху вы можете увидеть статический метод hash класса HashMap
.
Идея заключается в том, чтобы для каждого ключа иметь уникальный hash-код, но hash-функция может произвести одинаковый hash-код для разных ключей. Это приводит к ситуации, известной как столкновение. Мы посмотрим, как обрабатывать столкновения в следующей секции.
Расчет индекса
Once the hash code for a key is generated, the HashMap
calculates an index within the array of buckets to determine where the key-value pair will be stored. This is done using a bitwise AND operation, which is an efficient way to calculate the modulo when the array length is a power of two.
int index = (n - 1) & hash;
Здесь мы вычисляем индекс, где n – это длина массива коллекции.
Один раз индекс вычислен, ключ сохраняется в соответствующем месте массива коллекции. Тем не менее, если multiple ключи кончаются на том же индексе, это вызывает столкновение. В такой ситуации HashMap
обрабатывается двумя способами:
-
Chaining/Linking: Каждая ячейка в массиве является связанным списком узлов. Если ключ уже существует в определенной ячейке и еще один ключ hash-ется к этому же индексу, он добавляется в конец списка.
-
Treeify: Если количество узлов превышает определенный порог, связанный список преобразуется в дерево (это было введено в Java 8).
static final int TREEIFY_THRESHOLD = 8;
Этот порог определяет treeification.
Таким образом, важно иметь хорошую хэш-функцию, которая равномерно распределяет ключи по ящикам и минимизирует chances столкновений.
Operations извлечения (get
) и удаления (remove
) работают похоже, как операция вставки (put
). Вот как это работает:
-
Retrieval (
get
): Calculates hash code using the hash function -> calculates the index using the hash code -> traverses the linked list or tree to find the node with the matching key. -
Удаление (
remove
): вычисляет код хэш с помощью функции хэширования -> вычисляет индекс с помощью кода хэш -> удаляет узел из связанного списка или дерева.
Временная сложность
Основные операции HashMap
, такие как put
, get
и remove
, обычно обеспечивают постоянное время работы O(1), при условии, что ключи распределены равномерно. В тех случаях, когда распределение ключей плохое и происходит много столкновений, эти операции могут снижаться до линейной временной сложности O(n).
При использовании дерева, когда длинные цепочки столкновений преобразуются в сбалансированные дерева, операции поиска могут улучшиться до более эффективной логической сложности O(log n).
Синхронизация
Реализация HashMap
не синхронизирована. Если несколько потоков одновременно доступают к экземпляру HashMap и итерируются по карте, и если какой-либо из потоков выполняет структурные изменения (такие как добавление или удаление пар ключ-значение) в карту, это может привести к исключению ConcurrentModificationException
.
Чтобы предотвратить это, вы можете создать thread-safe экземпляр используя метод Collections.synchronizedMap
.
Conclusion
В общем, понимание внутреннего действия HashMap
важно для разработчиков, чтобы они могли принимать информированные решения. Знание того, как ключ сопоставляется, как происходят столкновения и как их можно избегать, помогает эффективно и успешно использовать HashMap
.
Source:
https://www.freecodecamp.org/news/how-java-hashmaps-work-internal-mechanics-explained/