Java HashMap под капотом

1. Обзор

В этой статье мы рассмотрим наиболее популярную реализацию интерфейса Map из Java Collections Framework.

Прежде чем приступить к реализации, важно отметить, что первичные интерфейсы коллекции List и Set расширяют Collection , а Map - нет.

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

Пары ключ-значение хранятся в так называемых сегментах, которые вместе составляют таблицу, которая на самом деле является внутренним массивом.

Как только мы узнаем ключ, под которым хранится или должен быть сохранен объект, операции хранения и поиска выполняются в постоянное время , O (1) в хэш-карте с большими размерами.

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

Наконец, HashMap часто встречающиеся вопросы в интервью , так что это хороший способ подготовить интервью или подготовиться к нему.

2. API put ()

Чтобы сохранить значение в хэш-карте, мы вызываем API put , который принимает два параметра; ключ и соответствующее значение:

V put(K key, V value);

Когда значение добавляется на карту под ключом, вызывается API hashCode () объекта ключа для получения так называемого начального значения хеш-функции.

Чтобы увидеть это в действии, давайте создадим объект, который будет действовать как ключ.

Мы создадим только один атрибут для использования в качестве хеш-кода для имитации первой фазы хеширования:

public class MyKey {
    private int id;

    @Override
    public int hashCode() {
        System.out.println("Calling hashCode()");
        return id;
    }

   //constructor, setters and getters
}

Теперь мы можем использовать этот объект для сопоставления значения в хэш-карте:

@Test
public void whenHashCodeIsCalledOnPut__thenCorrect() {
    MyKey key = new MyKey(1);
    Map<MyKey, String> map = new HashMap<>();
    map.put(key, "val");
}

В приведенном выше коде ничего особенного не происходит, но обратите внимание на вывод консоли. Действительно, метод hashCode вызывается:

Calling hashCode()

Затем, API-интерфейс hash () карты хеш-функции вызывается внутренне для вычисления окончательного значения хеш-функции с использованием начального значения хеш-функции.

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

Функция hash для HashMap выглядит следующим образом:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

Здесь следует отметить только использование хеш-кода из ключевого объекта для вычисления окончательного хеш-значения

Находясь внутри функции put , окончательное значение хеша используется следующим образом:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

Обратите внимание, что вызывается внутренняя функция putVal , и в качестве первого параметра указывается окончательное значение хеш-функции.

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

Причина в том, что хеш-карты хранят и ключ, и значение в расположении корзины как объект Map.Entry .

Как обсуждалось ранее, все интерфейсы инфраструктуры коллекций Java расширяют интерфейс Collection , а Map - нет. Сравните объявление интерфейса Map, которое мы видели ранее, с интерфейсом Set :

public interface Set<E> extends Collection<E>

Причина в том, что ** карты точно не хранят отдельные элементы, как в других коллекциях, а представляют собой набор пар ключ-значение.

Таким образом, универсальные методы интерфейса Collection , такие как add , toArray , не имеют смысла, когда дело доходит до Map .

Концепция, которую мы рассмотрели в последних трех абзацах, является одним из самых популярных вопросов для собеседования в Java Collections Framework .

Так что это стоит понять.

Один специальный атрибут хеш-карты заключается в том, что он принимает значения null и нулевые ключи:

@Test
public void givenNullKeyAndVal__whenAccepts__thenCorrect(){
    Map<String, String> map = new HashMap<>();
    map.put(null, null);
}
  • Когда во время операции put встречается пустой ключ, ему автоматически присваивается окончательное значение хеш-функции 0 ** , что означает, что он становится первым элементом базового массива.

Это также означает, что когда ключ имеет значение NULL, операция хеширования не выполняется, и, следовательно, API-ключ ключа hashCode не вызывается, что в конечном итоге исключает исключение нулевого указателя.

Во время операции put , когда мы используем ключ, который уже использовался ранее для хранения значения, он возвращает предыдущее значение, связанное с ключом:

@Test
public void givenExistingKey__whenPutReturnsPrevValue__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("key1", "val1");

    String rtnVal = map.put("key1", "val2");

    assertEquals("val1", rtnVal);
}

в противном случае возвращается null:

@Test
public void givenNewKey__whenPutReturnsNull__thenCorrect() {
    Map<String, String> map = new HashMap<>();

    String rtnVal = map.put("key1", "val1");

    assertNull(rtnVal);
}

Когда put возвращает значение NULL, это также может означать, что предыдущее значение, связанное с ключом, является NULL, не обязательно, что это новое отображение значения ключа:

@Test
public void givenNullVal__whenPutReturnsNull__thenCorrect() {
    Map<String, String> map = new HashMap<>();

    String rtnVal = map.put("key1", null);

    assertNull(rtnVal);
}

API containsKey можно использовать для различения таких сценариев, как мы увидим в следующем подразделе.

3. Get API

Чтобы получить объект, уже сохраненный в хэш-карте, мы должны знать ключ, под которым он был сохранен. Мы вызываем API get и передаем ему ключевой объект:

@Test
public void whenGetWorks__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("key", "val");

    String val = map.get("key");

    assertEquals("val", val);
}

Внутри используется тот же принцип хеширования. The hashCode () API ключевого объекта вызывается для получения начального значения хеша:

@Test
public void whenHashCodeIsCalledOnGet__thenCorrect() {
    MyKey key = new MyKey(1);
    Map<MyKey, String> map = new HashMap<>();
    map.put(key, "val");
    map.get(key);
}

На этот раз hashCode API MyKey вызывается дважды; один раз для put и один раз для get :

Calling hashCode()
Calling hashCode()

Затем это значение повторно обрабатывается путем вызова внутреннего API hash () для получения окончательного значения хеш-функции.

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

Объект значения, хранящийся в этом месте, затем извлекается и возвращается в вызывающую функцию.

Если возвращаемое значение равно нулю, это может означать, что ключевой объект не связан ни с одним значением в хэш-карте:

@Test
public void givenUnmappedKey__whenGetReturnsNull__thenCorrect() {
    Map<String, String> map = new HashMap<>();

    String rtnVal = map.get("key1");

    assertNull(rtnVal);
}

Или это может просто означать, что ключ был явно сопоставлен с нулевым экземпляром:

@Test
public void givenNullVal__whenRetrieves__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("key", null);

    String val=map.get("key");

    assertNull(val);
}

Чтобы провести различие между двумя сценариями, мы можем использовать API containsKey , которому мы передаем ключ, и он возвращает true, если и только если сопоставление было создано для указанного ключа в хэш-карте:

@Test
public void whenContainsDistinguishesNullValues__thenCorrect() {
    Map<String, String> map = new HashMap<>();

    String val1 = map.get("key");
    boolean valPresent = map.containsKey("key");

    assertNull(val1);
    assertFalse(valPresent);

    map.put("key", null);
    String val = map.get("key");
    valPresent = map.containsKey("key");

    assertNull(val);
    assertTrue(valPresent);
}

Для обоих случаев в приведенном выше тесте возвращаемое значение вызова API get равно нулю, но мы можем различить, какой из них есть какой.

4. Представления коллекции в HashMap

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

@Test
public void givenHashMap__whenRetrievesKeyset__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    Set<String> keys = map.keySet();

    assertEquals(2, keys.size());
    assertTrue(keys.contains("name"));
    assertTrue(keys.contains("type"));
}

Набор поддерживается самой картой. Так что любое изменение, внесенное в набор, отражается на карте :

@Test
public void givenKeySet__whenChangeReflectsInMap__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    assertEquals(2, map.size());

    Set<String> keys = map.keySet();
    keys.remove("name");

    assertEquals(1, map.size());
}

Мы также можем получить коллекцию значений :

@Test
public void givenHashMap__whenRetrievesValues__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    Collection<String> values = map.values();

    assertEquals(2, values.size());
    assertTrue(values.contains("baeldung"));
    assertTrue(values.contains("blog"));
}

Как и набор ключей, любые изменения, внесенные в эту коллекцию, будут отражены в базовой карте .

Наконец, мы можем получить set view всех записей на карте:

@Test
public void givenHashMap__whenRetrievesEntries__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    Set<Entry<String, String>> entries = map.entrySet();

    assertEquals(2, entries.size());
    for (Entry<String, String> e : entries) {
        String key = e.getKey();
        String val = e.getValue();
        assertTrue(key.equals("name") || key.equals("type"));
        assertTrue(val.equals("baeldung") || val.equals("blog"));
    }
}

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

Много раз вы будете использовать представления коллекции в цикле, как в последнем примере, а точнее, с помощью их итераторов.

Просто помните, что итераторы для всех представленных выше представлений - fail-fast .

Если на карте произведено какое-либо структурное изменение, то после создания итератора будет сгенерировано исключение одновременного изменения:

@Test(expected = ConcurrentModificationException.class)
public void givenIterator__whenFailsFastOnModification__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    Set<String> keys = map.keySet();
    Iterator<String> it = keys.iterator();
    map.remove("type");
    while (it.hasNext()) {
        String key = it.next();
    }
}

Единственная разрешенная структурная модификация - это операция remove , выполняемая самим итератором:

public void givenIterator__whenRemoveWorks__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    Set<String> keys = map.keySet();
    Iterator<String> it = keys.iterator();

    while (it.hasNext()) {
        it.next();
        it.remove();
    }

    assertEquals(0, map.size());
}

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

Итерация по хеш-карте происходит в худшем случае O (n) , где n - сумма его емкости и количества записей.

5. Производительность HashMap

На производительность хэш-карты влияют два параметра: Initial Capacity и Load Factor . Емкость - это количество сегментов или длина базового массива, а начальная емкость - просто емкость во время создания.

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

Начальная емкость по умолчанию - 16 , а коэффициент загрузки по умолчанию - 0.75 .

Мы можем создать хэш-карту с пользовательскими значениями для начальной емкости и LF:

Map<String,String> hashMapWithCapacity=new HashMap<>(32);
Map<String,String> hashMapWithCapacityAndLF=new HashMap<>(32, 0.5f);

Значения по умолчанию, установленные командой Java, хорошо оптимизированы для большинства случаев. Тем не менее, если вам нужно использовать свои собственные значения, что очень хорошо, вам нужно понимать влияние на производительность, чтобы вы знали, что делаете.

Когда количество записей в хэш-карте превышает произведение LF и емкости, происходит перефразировка , т. Е. Создается другой внутренний массив с удвоенным размером исходного и все записи перемещаются в новые области памяти в новом массиве ,

  • Низкая начальная емкость снижает стоимость помещения, но увеличивает частоту перефразирования ** . Перефразировка, очевидно, очень дорогой процесс. Поэтому, как правило, если вы ожидаете много записей, вы должны установить достаточно высокую начальную емкость.

С другой стороны, если вы установите слишком высокую начальную емкость, вы заплатите стоимость за время итерации. Как мы видели в предыдущем разделе.

Таким образом, высокая начальная емкость хороша для большого количества записей в сочетании с минимальной итерацией .

  • Низкая начальная емкость хороша для нескольких записей с большим количеством итераций ** .

6. Столкновения в HashMap

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

Этот сценарий может возникнуть из-за того, что согласно контракту equals и hashCode два неравных объекта в Java могут иметь одинаковый хэш-код .

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

Тем не менее, стоит упомянуть, что в Java реализована технология разрешения коллизий хеш-кода, которую мы увидим на примере.

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

И по умолчанию реализация использует связанный список в качестве реализации сегмента.

Первоначально операции с постоянным временем O (1) put и get будут выполняться за линейное время O (n) в случае столкновения. Это связано с тем, что после нахождения местоположения блока с окончательным значением хеш-функции каждый из ключей в этом местоположении будет сравниваться с предоставленным объектом ключа с использованием API equals

Чтобы смоделировать эту технику разрешения коллизий, давайте немного изменим наш ранее ключевой объект:

public class MyKey {
    private String name;
    private int id;

    public MyKey(int id, String name) {
        this.id = id;
        this.name = name;
    }

   //standard getters and setters

    @Override
    public int hashCode() {
        System.out.println("Calling hashCode()");
        return id;
    }

   //toString override for pretty logging

    @Override
    public boolean equals(Object obj) {
        System.out.println("Calling equals() for key: " + obj);
       //generated implementation
    }

}

Обратите внимание, что мы просто возвращаем атрибут id в качестве хеш-кода и, таким образом, вызываем конфликт.

Также обратите внимание, что мы добавили операторы log в наши реализации equals и hashCode - так что мы точно знаем, когда вызывается логика.

Давайте теперь продолжим, чтобы сохранить и извлечь некоторые объекты, которые сталкиваются в какой-то момент

@Test
public void whenCallsEqualsOnCollision__thenCorrect() {
    HashMap<MyKey, String> map = new HashMap<>();
    MyKey k1 = new MyKey(1, "firstKey");
    MyKey k2 = new MyKey(2, "secondKey");
    MyKey k3 = new MyKey(2, "thirdKey");

    System.out.println("storing value for k1");
    map.put(k1, "firstValue");
    System.out.println("storing value for k2");
    map.put(k2, "secondValue");
    System.out.println("storing value for k3");
    map.put(k3, "thirdValue");

    System.out.println("retrieving value for k1");
    String v1 = map.get(k1);
    System.out.println("retrieving value for k2");
    String v2 = map.get(k2);
    System.out.println("retrieving value for k3");
    String v3 = map.get(k3);

    assertEquals("firstValue", v1);
    assertEquals("secondValue", v2);
    assertEquals("thirdValue", v3);
}

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

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

Когда мы запустим тест, он должен пройти, показывая, что конфликты были разрешены, и мы будем использовать созданный журнал, чтобы подтвердить, что конфликты действительно произошли:

storing value for k1
Calling hashCode()
storing value for k2
Calling hashCode()
storing value for k3
Calling hashCode()
Calling equals() for key: MyKey[name=secondKey, id=2]retrieving value for k1
Calling hashCode()
retrieving value for k2
Calling hashCode()
retrieving value for k3
Calling hashCode()
Calling equals() for key: MyKey[name=secondKey, id=2]----

Обратите внимание, что во время операций хранения __k1__ и __k2__ были успешно сопоставлены своим значениям с использованием только хэш-кода.

Тем не менее, хранение __k3__ было не так просто, система обнаружила, что в месте ее размещения уже содержится сопоставление __k2__. Поэтому для их различения использовалось сравнение __equals__, и был создан связанный список, содержащий оба сопоставления.

Любое другое последующее сопоставление, чьи ключи хешируются в том же месте сегмента, будет следовать по тому же маршруту и ​​в конечном итоге заменяет один из узлов в связанном списке или будет добавлено в начало списка, если сравнение __equals__ возвращает false для всех существующих узлов.

Аналогично, во время поиска __k3__ и __k2__ сравнивались __equals__ для определения правильного ключа, значение которого должно быть получено.

В заключение, в Java 8 связанные списки динамически заменяются сбалансированными бинарными деревьями поиска в разрешении коллизий после того, как число коллизий в данном местоположении сегмента превысит определенный порог.

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

Этот раздел **  очень распространен в технических собеседованиях ** , особенно после базовых вопросов хранения и поиска.

===  **  7. Заключение**

В этой статье мы рассмотрели реализацию __HashMap__ интерфейса Java __Map__

Полный исходный код для всех примеров, использованных в этой статье, можно найти в проекте https://github.com/eugenp/tutorials/tree/master/java-collections-maps[GitHub].