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.