Введение

Привет! Эта статья будет полезна новичкам в разработке или тем, кому нужно освежить знания перед собеседованием. Как ни странно, но вопросы о том, как устроен HashMap, всё ещё популярны среди интервьюеров.

Раньше, когда я готовился к собеседованиям и изучал материалы на Хабре, у меня часто возникали сомнения в актуальности некоторых статей и их слишком теоретическом подходе. По моему мнению, лучший способ разобраться в теме — это заглянуть в документацию с примерами и/или изучить исходный код. Но если вы сами пока не готовы копаться в коде, я покажу и прокомментирую основные моменты. Версия java 21.

Внутренние параметры

Начнем с компонентов класса HashMap. На следующем скрине представлены поля класса HashMap:

Поля класса HashMap
Поля класса HashMap


Исследуемый класс имеет следующие поля:

  • table - это массив корзин (бакетов), класс Node опишу ниже

  • size - количество пар ключ - значение, хранящиеся в мапе

  • threshold - критическое количество заполненных ячеек массива бакетов, после которого происходит его расширение (зависит от capacity и loadFactor)

  • loadFactor - критическое значение процента заполнения бакетов в массиве для его расширения

Далее представлены константы. Некоторые из переменных можно задавать при инициализации массива вместо использования дефолтных значений:

Константы
Константы
  • DEFAULT_INITIAL_CAPACITY - размер массива бакетов по умолчанию

  • DEFAULT_LOAD_FACTOR - пороговый процент заполнения массива бакетов для расширения

  • TREEIFY_THRESHOLD - пороговое значение длинны списка в бакете для превращения списка в дерево (об этом подробнее ниже)

  • UNTREEIFY_THRESHOLD - пороговое значение для обратного превращения

  • MIN_TREEIFY_CAPACITY - минимальное значение длинны массива бакетов, при котором возможно превращение списка в дерево

Класс бакета (корзины)

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

Ниже представлен класс корзины (бакета), который описан внутри HashMap:

Внутренний класс Node (бакет)
Внутренний класс Node (бакет)


Бакет содержит ссылку на следующий бакет (next), поэтому каждая ячейка массива table содержит в себе связный список. (или дерево, см ниже)

При определенных обстоятельствах связный список из объектов Node трансформируется в дерево из объектов TreeNode. Полностью представить код класса TreeNode не получится, потому что он занимает около 600 строк. Ниже представлено начало класса с переменными:

Начало класса TreeNode
Начало класса TreeNode

Конструкторы

Класс hashMap содержит конструкторы со следующими параметрами:

  1. Конструктор с начальной емкостью массива бакетов (initialCapacity) и критическим процентом заполнения для расширения (loadFactor)

  2. Конструктор с начальной емкостью (initialCapacity)

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

  4. Конструктор, который принимает другую мапу.

Скриншот исходников:

Конструкторы класса HashMap
Конструкторы класса HashMap

Добавление элемента

Разберемся с процессом добавления элемента. В ходе разбора этой операции мы узнаем об особенностях HashMap.

Посмотреть исходник метода добавления:
/**  
 * Implements Map.put and related methods. 
 * 
 * @param hash hash for key  
 * @param key the key  
 * @param value the value to put  
 * @param onlyIfAbsent if true, don't change existing value  
 * @param evict if false, the table is in creation mode.  
 * @return previous value, or null if none  
 */
 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,  
               boolean evict) {  
    Node[] tab; Node p; int n, i;  
    
    // ШАГ №1
    if ((tab = table) == null || (n = tab.length) == 0)  
        n = (tab = resize()).length;  
        
    // ШАГ №2
    if ((p = tab[i = (n - 1) & hash]) == null)  
        tab[i] = newNode(hash, key, value, null);  
    else {
        Node e; K k;
        
        // ШАГ №3  
        if (p.hash == hash &&  
            ((k = p.key) == key || (key != null && key.equals(k))))  
            e = p;
            
        // ШАГ №4  
        else if (p instanceof TreeNode)  
            e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
            
        // ШАГ №5
        else {  
            for (int binCount = 0; ; ++binCount) {  
                if ((e = p.next) == null) {  
                    p.next = newNode(hash, key, value, null);  
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st  
                        treeifyBin(tab, hash);  
                    break;  
                }  
                if (e.hash == hash &&  
                    ((k = e.key) == key || (key != null && key.equals(k))))  
                    break;  
                p = e;  
            }  
        }  

		// Шаг №6
        if (e != null) { // existing mapping for key  
            V oldValue = e.value;  
            if (!onlyIfAbsent || oldValue == null)  
                e.value = value;  
            afterNodeAccess(e);  
            return oldValue;  
        }  
    }  
    ++modCount;  

	// ШАГ №7
    if (++size > threshold)  
        resize();  
    afterNodeInsertion(evict);  
    return null;  
}

Шаг №1
На первом этапе происходит проверка инициализации массива бакетов. Если массив бакетов еще не был инициализирован или имеет нулевую длину, то он
инициализируется и расширяется до дефолтных значений.

Шаг №2
На основе хэша ключа выбирается индекс в массиве бакетов (через битовую операцию &). Если по этому индексу в массиве нет бакета, то он создается.

Подробнее про битовую операцию &

Оператор & сравнивает первый бит первого числа с первым битом второго. Поскольку это оператор “И”, то результат будет равен 1 только в том случае, если оба бита равны 1. Во всех остальных случаях результатом будет 0.

Пример:

100010101
&
110110000

-----------------

100010000

Шаг №3
Если по вычисленному индексу уже есть бакет, то следующим этапом происходит проверка идентичности этого бакета и добавляемого элемента. Проверка происходит по хэшу ключа и равенству ссылки на ключ или равенству самого ключа.

Шаг №4
Если бакет является элементом дерева, то вызывается метод для добавления элемента в дерево

Шаг №5
На этом этапе происходит последовательное прохождение по связному списку в бакете. Если находим идентичный ключ (по ссылке или значению с таким хэшом), то сохраняем эту ссылку и проходим к следующему шагу. Если новый элемент уникальный, то вставляем его в самый конец списка. При этом, если при количество элементов в списке превышает значение TREEFY_TRESHOLD, то происходит видоизменение связного списка в дерево.

Шаг №6
На этом этапе происходит замена старого значения на новое, если мы пытаемся положить значение по уже существующему ключу.

Шаг №7
Далее происходит увеличение длины массива бакетов, если количество элементов в массиве превышает пороговое значение (THRESHOLD).

Алгоритмическая сложность:
Сложность добавления элемента зависит от состояния массива бакетов, а это зависит от хэш функции:

  • Если массив бакетов не содержит связных списков, то есть у каждого бакета нет следующего элемента, то получение элемента всегда будет занимать примерно одинаковое время: вычисляем ячейку массива по хэшу -> достаем бакет и выдаем значение. Следовательно, сложность будет O(1)

  • В самом худшем случае, когда у нас хэш функция работает плохо и всегда выдает одно и то же значение, то все элементы будут помещаться в один бакет и вся мапа станет связным списком, тогда будет сложность O(n). Эта сложность будет наблюдаться до определенного момента.

  • При увеличении длинны связного списка в бакете более 8 и достаточной длинны массива бакетов происходит преобразование списка в дерево, поэтому сложность станет равной O(logN)

Получение элемента

Метод получения элемента представлен ниже:

public V get(Object key) {  
    Node e;  
    return (e = getNode(key)) == null ? null : e.value;  
}

В случае отсутствия ключа в мапе возвращается null, поэтому стоит предварительно проверять наличие пары через containsKey(Object key), иначе можем получить NullPointerException в дальнейшем.

Посмотреть исходник метода получения значения:
final Node getNode(Object key) {  
    Node[] tab; Node first, e; int n, hash; K k;
    
    // Шаг №1  
    if ((tab = table) != null && (n = tab.length) > 0 &&  
        (first = tab[(n - 1) & (hash = hash(key))]) != null) {  
        
        // Шаг №2
        if (first.hash == hash && // always check first node  
            ((k = first.key) == key || (key != null && key.equals(k))))  
            return first;  

		// Шаг №3
        if ((e = first.next) != null) {  
            if (first instanceof TreeNode)  
                return ((TreeNode)first).getTreeNode(hash, key);  
            do {  
                if (e.hash == hash &&  
                    ((k = e.key) == key || (key != null && key.equals(k))))  
                    return e;  
            } while ((e = e.next) != null);  
        }  
    }  
    return null;  
}

Шаг №1
Сначала происходят проверки на инициализацию массива бакетов, наличие бакетов в массиве и существование бакета в ячейке массива, которую мы получаем по хэшу ключа.

Шаг №2
На этом шаге проверяется первый элемент в бакете, если он соответствует запрашиваемому ключу, то он и возвращается.

Шаг №3
Если не подходит первый элемент в массиве бакетов, то для поиска соответствующего элемента происходит перебор связного списка или дерева в бакете.

В случае отсутствия элемента возвращается null.

Алгоритмическая сложность идентична алгоритму добавления элемента.

Пример

Далее представлен пример, в котором в качестве ключа используется строка. В debug режиме мы можем получать доступ к приватным переменным. На скриншоте мы можем увидеть, что при добавлении трех значений в мапу они все попадают в один бакет и образуют связный список. Кроме этого, видно, что threshold = 12, это составляет 0,75 от стандартной длинны массива бакетов (16). При достижении 12 элементов массив бакетов будет удвоен.

Пример для иллюстрации параметров HashMap
Пример для иллюстрации параметров HashMap

Выводы

  • HashMap представляет собой массив бакетов, каждый бакет в этом массиве может быть головой связного списка или дерева.

  • Вставка и получение элементов имеют имеют сложность O(1) или О(n), или O(logN) в зависимости от состояния мапы и работы хэш-функции.

  • Эффективность работы мапы зависит от алгоритма хэширования ключа

  • Если делаем вставку с ключом, который уже есть в мапе, то значение перезаписывается

  • Массив бакетов расширяется при достижении критического значения

Комментарии (19)


  1. LeshaRB
    19.01.2025 18:03

    Мне в свое время зашли такие статьи
    https://habr.com/ru/articles/128017/

    У автора ещё немного есть


    1. Gabenskiy Автор
      19.01.2025 18:03

      Согласен, для своего времени статья замечательная


      1. dph
        19.01.2025 18:03

        А чем она хуже этой статьи?


  1. dersoverflow
    19.01.2025 18:03

    а бывает HashMap с транзакциями. но на Жабе у вас не получится.

    https://ders.by/cpp/memdb/memdb.html


    1. Politura
      19.01.2025 18:03

      В Джаве для этого есть ConcurrentHashMap из коробки, и никакие велосипеды изобретать не надо.


      1. dersoverflow
        19.01.2025 18:03

        смиялсо.

        ну покажите мне еще один "велосипед", где копирование объекта HashMap со всеми ее элементами - это просто memcpy()? джависты даже не поймут суть вопроса.


  1. avost
    19.01.2025 18:03

    Шо, опять?!?

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


    1. Gabenskiy Автор
      19.01.2025 18:03

      А какие темы были бы вам интересны?


  1. dph
    19.01.2025 18:03

    А когда это получение значения из hashmap имеет сложность o(n)?
    Или что значит фраза " Вставка и получение элементов имеют имеют сложность O(1) или О(n), или O(logN)"?


    1. kulity
      19.01.2025 18:03

      Если у добавленных значений совпадает хэш, они попадут в один бакет в формате связного списка. Работа со списком как раз и даёт O(N). Но, при достижении определенного размера, список будет преобразован в дерево, где сложность уже O(logN), в целях оптимизации.

      Такая ситуация возможна, если функция хэширования реализована плохо и, как следствие, имеет много коллизий


      1. dph
        19.01.2025 18:03

        Это не совсем верное утверждение. Список в бакете не превышает 8 элементов, так что O(N) получить никак не получится.


        1. rexer
          19.01.2025 18:03

          Все так, если количество элементов в бакете превышает TREEIFY_THRESHOLD (который как раз и равен 8), то будет вызван метод treeifyBin и бакет преобразуется в красно-черное дерево.

          Благодаря этому, начиная с Java 8+ производительность java.util.HashMap в худшем случае не O(n), а O(log n).


          1. dph
            19.01.2025 18:03

            Я, скорее, хотел показать, что автор статьей не совсем понял тот код, который комментирует. Иначе не сказал бы про O(n) для HashMap.
            Впрочем, статья изначально бесполезная.


  1. kulity
    19.01.2025 18:03

    В случае отсутствия ключа в мапе возвращается null, поэтому стоит предварительно проверять наличие пары через containsKey(Object key), иначе можем получить NullPointerException в дальнейшем.

    Вредительский совет. Зачем для получения значения 2 раза осуществлять его поиск? Просто результат получения значения на null проверять и всё


    1. PrinceKorwin
      19.01.2025 18:03

      Более того. В многопотоке (если кто-то накосячил), то можно от if xxx.containsKey() получить истину, а далее получить null при вычитывании сущности.


      1. jbourne
        19.01.2025 18:03

        Добавлю, что в ConcurrentHashMap null ложить нельзя. Но проблема может возникнуть в синхронайзед оббертках типа Collections.synchronizedMap().


    1. jbourne
      19.01.2025 18:03

      Согласен. Лишнего вызова стоит избегать всегда по дефолту.

      В целом проблему с null можно решить так:
      - не ложить null
      - ложить "специальное" пустое значение
      - проверять через .containsXXX()
      - перестроить логику, что бы было не важно "нет или null"


  1. jbourne
    19.01.2025 18:03

    Хорошо. + я бы добавил сюда еще инфу по памяти потребляемой. Ведь это всегда конфликт - память/скорость.

    Грубо говоря например, тюнингом я могу больше приблизить скорость к О(1), если буду увеличивать число бакетов (уменьшая лоад фактор и число потенциальных коллизий для "нехорошего" хеша). Но тогда взлетит память. И в том "на сколько", и будет заключаться дилемма, которую нужно будет решать оптимизатору.

    Поэтому я бы не разделял скорость и память.


  1. rexer
    19.01.2025 18:03

    Вы не упомянули важную вещь, а именно - это то, как список будет преобразовываться в дерево (и в какое дерево именно), а также вклад в таком случае Comparable.
    Вот тут я разбирал как это будет происходить: JBook/HashMap#коллизии

    Ну и вообще, сама заметка про HashMap, может будет кому полезно: JBook