Перейти к основному содержимому

Урок по Java HashMap

· 10 мин. чтения

1. Обзор

В этой статье мы увидим, как использовать HashMap в Java, и посмотрим, как это работает внутри.

Класс, очень похожий на HashMap , — это Hashtable . Пожалуйста, обратитесь к паре других наших статей, чтобы узнать больше о самом классе java.util.Hashtable и различиях между HashMap и Hashtable .

2. Основное использование

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

Можно спросить, почему бы просто не добавить значение в список. Зачем нам нужен HashMap ? Причина проста: производительность. Если мы хотим найти конкретный элемент в списке, временная сложность будет O(n) , а если список отсортирован, то будет O(log n) с использованием, например, бинарного поиска.

Преимущество HashMap заключается в том, что временная сложность вставки и извлечения значения составляет в среднем O(1) . Мы рассмотрим, как этого можно достичь позже. Давайте сначала посмотрим, как использовать HashMap .

2.1. Настраивать

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

public class Product {

private String name;
private String description;
private List<String> tags;

// standard getters/setters/constructors

public Product addTagsOfOtherProduct(Product product) {
this.tags.addAll(product.getTags());
return this;
}
}

2.2. Помещать

Теперь мы можем создать HashMap с ключом типа String и элементами типа Product :

Map<String, Product> productsByName = new HashMap<>();

И добавляем товары в наш HashMap :

Product eBike = new Product("E-Bike", "A bike with a battery");
Product roadBike = new Product("Road bike", "A bike for competition");
productsByName.put(eBike.getName(), eBike);
productsByName.put(roadBike.getName(), roadBike);

2.3. Получить

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

Product nextPurchase = productsByName.get("E-Bike");
assertEquals("A bike with a battery", nextPurchase.getDescription());

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

Product nextPurchase = productsByName.get("Car");
assertNull(nextPurchase);

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

Product newEBike = new Product("E-Bike", "A bike with a better battery");
productsByName.put(newEBike.getName(), newEBike);
assertEquals("A bike with a better battery", productsByName.get("E-Bike").getDescription());

2.4. Нуль как ключ

HashMap также позволяет нам использовать null в качестве ключа:

Product defaultProduct = new Product("Chocolate", "At least buy chocolate");
productsByName.put(null, defaultProduct);

Product nextPurchase = productsByName.get(null);
assertEquals("At least buy chocolate", nextPurchase.getDescription());

2.5. Значения с одним и тем же ключом

Кроме того, мы можем дважды вставить один и тот же объект с другим ключом:

productsByName.put(defaultProduct.getName(), defaultProduct);
assertSame(productsByName.get(null), productsByName.get("Chocolate"));

2.6. Удалить значение

Мы можем удалить сопоставление ключ-значение из HashMap :

productsByName.remove("E-Bike");
assertNull(productsByName.get("E-Bike"));

2.7. Проверьте, существует ли ключ или значение на карте

Чтобы проверить, присутствует ли ключ на карте, мы можем использовать метод containsKey() :

productsByName.containsKey("E-Bike");

Или, чтобы проверить, присутствует ли значение на карте, мы можем использовать метод containsValue() :

productsByName.containsValue(eBike);

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

2.8. Итерация по HashMap

Существует три основных способа перебора всех пар ключ-значение в HashMap .

Мы можем перебрать набор всех ключей:

for(String key : productsByName.keySet()) {
Product product = productsByName.get(key);
}

Или мы можем перебрать набор всех записей:

for(Map.Entry<String, Product> entry : productsByName.entrySet()) {
Product product = entry.getValue();
String key = entry.getKey();
//do something with the key and value
}

Наконец, мы можем перебрать все значения:

List<Product> products = new ArrayList<>(productsByName.values());

3. Ключ

Мы можем использовать любой класс в качестве ключа в нашей HashMap . Однако, чтобы карта работала правильно, нам нужно предоставить реализацию для equals() и hashCode(). Допустим, мы хотим иметь карту с продуктом в качестве ключа и ценой в качестве значения:

HashMap<Product, Integer> priceByProduct = new HashMap<>();
priceByProduct.put(eBike, 900);

Давайте реализуем методы equals() и hashCode() :

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}

Product product = (Product) o;
return Objects.equals(name, product.name) &&
Objects.equals(description, product.description);
}

@Override
public int hashCode() {
return Objects.hash(name, description);
}

Обратите внимание, что hashCode() и equals() необходимо переопределять только для классов, которые мы хотим использовать в качестве ключей карты, а не для классов, которые используются только как значения в карте. Мы увидим, почему это необходимо, в разделе 5 этой статьи.

4. Дополнительные методы начиная с Java 8

Java 8 добавила в HashMap несколько методов функционального стиля . В этом разделе мы рассмотрим некоторые из этих методов.

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

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

4.1. для каждого()

Метод forEach — это функциональный способ перебора всех элементов карты:

productsByName.forEach( (key, product) -> {
System.out.println("Key: " + key + " Product:" + product.getDescription());
//do something with the key and value
});

До Java 8:

for(Map.Entry<String, Product> entry : productsByName.entrySet()) {
Product product = entry.getValue();
String key = entry.getKey();
//do something with the key and value
}

В нашей статье «Руководство по Java 8 `` forEach» цикл forEach рассматривается более подробно.

4.2. получить или по умолчанию ()

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

Product chocolate = new Product("chocolate", "something sweet");
Product defaultProduct = productsByName.getOrDefault("horse carriage", chocolate);
Product bike = productsByName.getOrDefault("E-Bike", chocolate);

До Java 8:

Product bike2 = productsByName.containsKey("E-Bike") 
? productsByName.get("E-Bike")
: chocolate;
Product defaultProduct2 = productsByName.containsKey("horse carriage")
? productsByName.get("horse carriage")
: chocolate;

4.3. поставитьеслиотсутствует()

С помощью этого метода мы можем добавить новое сопоставление, но только если для данного ключа еще нет сопоставления:

productsByName.putIfAbsent("E-Bike", chocolate);

До Java 8:

if(productsByName.containsKey("E-Bike")) {
productsByName.put("E-Bike", chocolate);
}

В нашей статье « Объединение двух карт с Java 8 » этот метод рассматривается более подробно.

4.4. объединить()

И с помощью merge() мы можем изменить значение для данного ключа, если сопоставление существует, или добавить новое значение в противном случае:

Product eBike2 = new Product("E-Bike", "A bike with a battery");
eBike2.getTags().add("sport");
productsByName.merge("E-Bike", eBike2, Product::addTagsOfOtherProduct);

До Java 8:

if(productsByName.containsKey("E-Bike")) {
productsByName.get("E-Bike").addTagsOfOtherProduct(eBike2);
} else {
productsByName.put("E-Bike", eBike2);
}

4.5. вычислить()

С помощью метода calculate() мы можем вычислить значение для данного ключа:

productsByName.compute("E-Bike", (k,v) -> {
if(v != null) {
return v.addTagsOfOtherProduct(eBike2);
} else {
return eBike2;
}
});

До Java 8:

if(productsByName.containsKey("E-Bike")) {    
productsByName.get("E-Bike").addTagsOfOtherProduct(eBike2);
} else {
productsByName.put("E-Bike", eBike2);
}

Стоит отметить, что методы merge() и calculate() очень похожи. Метод calculate() принимает два аргумента: ключ и BiFunction для переназначения. И merge() принимает три параметра: ключ , значение по умолчанию для добавления к карте, если ключ еще не существует, и BiFunction для переназначения.

5. Внутреннее устройство HashMap

В этом разделе мы рассмотрим внутреннюю работу HashMap и преимущества использования HashMap вместо простого списка, например.

Как мы видели, мы можем получить элемент из HashMap по его ключу. Один из подходов — использовать список, перебирать все элементы и возвращаться, когда мы находим элемент, для которого совпадает ключ. Как временная, так и пространственная сложность этого подхода будут O(n) .

С помощью HashMap мы можем достичь средней временной сложности O(1) для операций ввода и получения и пространственной сложности O(n) . Давайте посмотрим, как это работает.

5.1. Хэш-код и эквиваленты

Вместо перебора всех своих элементов HashMap пытается вычислить положение значения на основе его ключа.

Наивным подходом было бы иметь список, который может содержать столько элементов, сколько возможно ключей. В качестве примера предположим, что наш ключ — это символ нижнего регистра. Тогда достаточно иметь список размером 26, и если мы хотим получить доступ к элементу с помощью ключа 'c', мы будем знать, что это элемент в позиции 3, и мы можем получить его напрямую.

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

HashMap хранит элементы в так называемых сегментах, а количество сегментов называется емкостью .

Когда мы помещаем значение на карту, метод hashCode() ключа используется для определения корзины, в которой будет храниться значение.

Чтобы получить значение, HashMap вычисляет корзину таким же образом — с помощью hashCode() . Затем он перебирает объекты, найденные в этом сегменте, и использует метод equals() ключа, чтобы найти точное совпадение.

5.2. Неизменяемость ключей

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

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

В этом примере мы создадим MutableKey :

public class MutableKey {
private String name;

// standard constructor, getter and setter

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
MutableKey that = (MutableKey) o;
return Objects.equals(name, that.name);
}

@Override
public int hashCode() {
return Objects.hash(name);
}
}

А вот и тест:

MutableKey key = new MutableKey("initial");

Map<MutableKey, String> items = new HashMap<>();
items.put(key, "success");

key.setName("changed");

assertNull(items.get(key));

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

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

5.3. Столкновения

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

Начиная с Java 8 (см. JEP 180 ), структура данных, в которой хранятся значения внутри одного сегмента, изменяется со списка на сбалансированное дерево, если сегмент содержит 8 или более значений, и обратно на список, если в момент в какой-то момент в ведре осталось только 6 значений. Это повышает производительность до O(log n) .

5.4. Вместимость и коэффициент нагрузки

Чтобы избежать большого количества сегментов с несколькими значениями, емкость удваивается, если 75% (коэффициент загрузки) сегментов становятся непустыми. Значение по умолчанию для коэффициента загрузки составляет 75%, а начальная мощность по умолчанию — 16. Оба параметра можно установить в конструкторе.

5.5. Сводка по операциям размещения и получения

Подытожим, как работают операции put и get .

Когда мы добавляем элемент на карту, HashMap вычисляет корзину. Если ведро уже содержит значение, оно добавляется в список (или дерево), принадлежащее этому ведру. Если коэффициент загрузки становится больше, чем максимальный коэффициент загрузки карты, вместимость удваивается.

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

6. Заключение

В этой статье мы увидели, как использовать HashMap и как он работает внутри. Наряду с ArrayList , HashMap является одной из наиболее часто используемых структур данных в Java, поэтому очень полезно хорошо знать, как ее использовать и как она работает «внутри». Наша статья The Java HashMap Under the Hood более подробно описывает внутренности HashMap .

Как обычно, полный исходный код доступен на Github .