Java HashMap под капака

1. Общ преглед

В тази статия ще разгледаме по-подробно най-популярната реализация на интерфейса Map от Java Collections Framework, като продължим там, където нашата статия за въведение е спряла.

Преди да започнем с внедряването, важно е да отбележим, че първичните интерфейси за събиране List и Set разширяват Collection, но Map не.

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

Двойките ключ-стойност се съхраняват в това, което е известно като групи, които заедно съставляват това, което се нарича таблица, която всъщност е вътрешен масив.

След като разберем ключа, под който се съхранява или трябва да се съхранява обект, операциите по съхранение и извличане се извършват през постоянно време , O (1) в добре оразмерена хеш карта.

За да се разбере как хеш картите работят под капака, трябва да се разбере механизмът за съхранение и извличане, използван от HashMap. Ще се съсредоточим много върху тях.

И накрая, свързаните с HashMap въпроси са доста често срещани в интервютата , така че това е солиден начин или да подготвите интервю, или да се подготвите за него.

2. API на put ()

За да съхраним стойност в хеш карта, извикваме put API, който взема два параметъра; ключ и съответната стойност:

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 map = new HashMap(); map.put(key, "val"); }

Нищо особено не се случва в горния код, но обърнете внимание на изхода на конзолата. Всъщност методът hashCode се извиква:

Calling hashCode()

След това API на hash () на хеш картата се извиква вътрешно, за да се изчисли крайната хеш стойност, като се използва началната хеш стойност.

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

В хеш функцията на HashMap изглежда така:

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

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

Докато вътре в функцията за пут , крайната хеш стойност се използва по следния начин:

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

Забележете, че се извиква вътрешна функция putVal и се дава крайната хеш стойност като първи параметър.

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

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

Както беше обсъдено по-горе, всички интерфейси на рамки на Java колекции разширяват интерфейса на колекция, но Map не. Сравнете декларацията за интерфейс на карта, която видяхме по-рано, с декларацията за интерфейс Set :

public interface Set extends Collection

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

Така че общите методи на интерфейса за събиране като add , toArray нямат смисъл, когато става въпрос за Map .

Концепцията, която разгледахме в последните три параграфа, прави един от най -популярните въпроси за интервюта на Java Collections Framework . Така че, струва си да се разбере.

Един специален атрибут с хеш картата е, че тя приема нулеви стойности и нулеви ключове:

@Test public void givenNullKeyAndVal_whenAccepts_thenCorrect(){ Map map = new HashMap(); map.put(null, null); }

Когато по време на пут операция се срещне нулев ключ , на него автоматично се присвоява крайна хеш стойност 0 , което означава, че той става първият елемент на базовия масив.

Това също означава, че когато ключът е нула, няма операция за хеширане и следователно не се извиква API на hashCode на ключа, като в крайна сметка се избягва изключение от нулев указател.

По време на операция пут , когато използваме ключ, който вече е бил използван преди това за съхраняване на стойност, той връща предишната стойност, свързана с ключа:

@Test public void givenExistingKey_whenPutReturnsPrevValue_thenCorrect() { Map map = new HashMap(); map.put("key1", "val1"); String rtnVal = map.put("key1", "val2"); assertEquals("val1", rtnVal); }

в противен случай връща null:

@Test public void givenNewKey_whenPutReturnsNull_thenCorrect() { Map map = new HashMap(); String rtnVal = map.put("key1", "val1"); assertNull(rtnVal); }

Когато put връща null, това също може да означава, че предишната стойност, свързана с ключа, е null, не е задължително да е ново картографиране ключ-стойност:

@Test public void givenNullVal_whenPutReturnsNull_thenCorrect() { Map map = new HashMap(); String rtnVal = map.put("key1", null); assertNull(rtnVal); }

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

3. API за получаване

За да извлечем обект, който вече се съхранява в хеш картата, трябва да знаем ключа, под който е бил съхранен. Извикваме API на get и му предаваме ключовия обект:

@Test public void whenGetWorks_thenCorrect() { Map map = new HashMap(); map.put("key", "val"); String val = map.get("key"); assertEquals("val", val); }

Вътрешно се използва същия принцип на хеширане. API на hashCode () на ключовия обект се извиква, за да се получи началната хеш стойност:

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

Този път API на hashCode на MyKey се извиква два пъти; веднъж за поставяне и веднъж за получаване :

Calling hashCode() Calling hashCode()

След това тази стойност се преизчиства чрез извикване на вътрешния API на hash (), за да се получи крайната стойност на хеш.

As we saw in the previous section, this final hash value ultimately boils down to a bucket location or an index of the internal array.

The value object stored in that location is then retrieved and returned to the calling function.

When the returned value is null, it could mean that the key object is not associated with any value in the hash map:

@Test public void givenUnmappedKey_whenGetReturnsNull_thenCorrect() { Map map = new HashMap(); String rtnVal = map.get("key1"); assertNull(rtnVal); }

Or it could simply mean that the key was explicitly mapped to a null instance:

@Test public void givenNullVal_whenRetrieves_thenCorrect() { Map map = new HashMap(); map.put("key", null); String val=map.get("key"); assertNull(val); }

To distinguish between the two scenarios, we can use the containsKey API, to which we pass the key and it returns true if and only if a mapping was created for the specified key in the hash map:

@Test public void whenContainsDistinguishesNullValues_thenCorrect() { Map 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); }

For both cases in the above test, the return value of the get API call is null but we are able to distinguish which one is which.

4. Collection Views in HashMap

HashMap offers three views that enable us to treat its keys and values as another collection. We can get a set of all keys of the map:

@Test public void givenHashMap_whenRetrievesKeyset_thenCorrect() { Map map = new HashMap(); map.put("name", "baeldung"); map.put("type", "blog"); Set keys = map.keySet(); assertEquals(2, keys.size()); assertTrue(keys.contains("name")); assertTrue(keys.contains("type")); }

The set is backed by the map itself. So any change made to the set is reflected in the map:

@Test public void givenKeySet_whenChangeReflectsInMap_thenCorrect() { Map map = new HashMap(); map.put("name", "baeldung"); map.put("type", "blog"); assertEquals(2, map.size()); Set keys = map.keySet(); keys.remove("name"); assertEquals(1, map.size()); }

We can also obtain a collection view of the values:

@Test public void givenHashMap_whenRetrievesValues_thenCorrect() { Map map = new HashMap(); map.put("name", "baeldung"); map.put("type", "blog"); Collection values = map.values(); assertEquals(2, values.size()); assertTrue(values.contains("baeldung")); assertTrue(values.contains("blog")); }

Just like the key set, any changes made in this collection will be reflected in the underlying map.

Finally, we can obtain a set view of all entries in the map:

@Test public void givenHashMap_whenRetrievesEntries_thenCorrect() { Map map = new HashMap(); map.put("name", "baeldung"); map.put("type", "blog"); Set
    
      entries = map.entrySet(); assertEquals(2, entries.size()); for (Entry e : entries) }
    

Remember that a hash map specifically contains unordered elements, therefore we assume any order when testing the keys and values of entries in the for each loop.

Many times, you will use the collection views in a loop as in the last example, and more specifically using their iterators.

Just remember that the iterators for all the above views are fail-fast.

If any structural modification is made on the map, after the iterator has been created, a concurrent modification exception will be thrown:

@Test(expected = ConcurrentModificationException.class) public void givenIterator_whenFailsFastOnModification_thenCorrect() { Map map = new HashMap(); map.put("name", "baeldung"); map.put("type", "blog"); Set keys = map.keySet(); Iterator it = keys.iterator(); map.remove("type"); while (it.hasNext()) { String key = it.next(); } }

The only allowed structural modification is a remove operation performed through the iterator itself:

public void givenIterator_whenRemoveWorks_thenCorrect() { Map map = new HashMap(); map.put("name", "baeldung"); map.put("type", "blog"); Set keys = map.keySet(); Iterator it = keys.iterator(); while (it.hasNext()) { it.next(); it.remove(); } assertEquals(0, map.size()); }

The final thing to remember about these collection views is the performance of iterations. This is where a hash map performs quite poorly compared with its counterparts linked hash map and tree map.

Iteration over a hash map happens in worst case O(n) where n is the sum of its capacity and the number of entries.

5. HashMap Performance

The performance of a hash map is affected by two parameters: Initial Capacity and Load Factor. The capacity is the number of buckets or the underlying array length and the initial capacity is simply the capacity during creation.

The load factor or LF, in short, is a measure of how full the hash map should be after adding some values before it is resized.

The default initial capacity is 16 and default load factor is 0.75. We can create a hash map with custom values for initial capacity and LF:

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

The default values set by the Java team are well optimized for most cases. However, if you need to use your own values, which is very okay, you need to understand the performance implications so that you know what you are doing.

When the number of hash map entries exceeds the product of LF and capacity, then rehashing occurs i.e. another internal array is created with twice the size of the initial one and all entries are moved over to new bucket locations in the new array.

A low initial capacity reduces space cost but increases the frequency of rehashing. Rehashing is obviously a very expensive process. So as a rule, if you anticipate many entries, you should set a considerably high initial capacity.

On the flip side, if you set the initial capacity too high, you will pay the cost in iteration time. As we saw in the previous section.

So a high initial capacity is good for a large number of entries coupled with little to no iteration.

A low initial capacity is good for few entries with a lot of iteration.

6. Collisions in the HashMap

A collision, or more specifically, a hash code collision in a HashMap, is a situation where two or more key objects produce the same final hash value and hence point to the same bucket location or array index.

This scenario can occur because according to the equals and hashCode contract, two unequal objects in Java can have the same hash code.

It can also happen because of the finite size of the underlying array, that is, before resizing. The smaller this array, the higher the chances of collision.

That said, it's worth mentioning that Java implements a hash code collision resolution technique which we will see using an example.

Keep in mind that it's the hash value of the key that determines the bucket the object will be stored in. And so, if the hash codes of any two keys collide, their entries will still be stored in the same bucket.

And by default, the implementation uses a linked list as the bucket implementation.

The initially constant time O(1)put and get operations will occur in linear time O(n) in the case of a collision. This is because after finding the bucket location with the final hash value, each of the keys at this location will be compared with the provided key object using the equals API.

To simulate this collision resolution technique, let's modify our earlier key object a little:

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 } }

Notice how we're simply returning the id attribute as the hash code – and thus force a collision to occur.

Also, note that we've added log statements in our equals and hashCode implementations – so that we know exactly when the logic is called.

Let's now go ahead to store and retrieve some objects that collide at some point:

@Test public void whenCallsEqualsOnCollision_thenCorrect() { HashMap 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); }

In the above test, we create three different keys – one has a unique id and the other two have the same id. Since we use id as the initial hash value, there will definitely be a collision during both storage and retrieval of data with these keys.

In addition to that, thanks to the collision resolution technique we saw earlier, we expect each of our stored values to be retrieved correctly, hence the assertions in the last three lines.

When we run the test, it should pass, indicating that collisions were resolved and we will use the logging produced to confirm that the collisions indeed occurred:

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]

Notice that during storage operations, k1 and k2 were successfully mapped to their values using only the hash code.

However, storage of k3 was not so simple, the system detected that its bucket location already contained a mapping for k2. Therefore, equals comparison was used to distinguish them and a linked list was created to contain both mappings.

Any other subsequent mapping whose key hashes to the same bucket location will follow the same route and end up replacing one of the nodes in the linked list or be added to the head of the list if equals comparison returns false for all existing nodes.

Likewise, during retrieval, k3 and k2 were equals-compared to identify the correct key whose value should be retrieved.

On a final note, from Java 8, the linked lists are dynamically replaced with balanced binary search trees in collision resolution after the number of collisions in a given bucket location exceed a certain threshold.

This change offers a performance boost, since, in the case of a collision, storage and retrieval happen in O(log n).

This section is very common in technical interviews, especially after the basic storage and retrieval questions.

7. Conclusion

In this article, we have explored HashMap implementation of Java Map interface.

Пълният изходен код за всички примери, използвани в тази статия, може да бъде намерен в проекта GitHub.