我们经常在平时工作及面试中,经常使用和问道HashMap的问题,本文将从以下几个方面进行记录:
1、什么是哈希表
2、HashMap实现原理
3、为何HashMap的数组长度一定是2的次幂?
1、什么是哈希表
在讨论哈希表之前,我们先大概了解下其他数据结构在新增、查找等基础操作执行性能。
数组:采用一段连续的存储单元来存储数据,对于制定下表的查找,事件复杂度是O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度是O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度为O(n)
线性链表: 对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,时间复杂度为O(n)
二叉树: 对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)
哈希表: 相比上述几种数据结构,在哈希表中进行添加、删除、查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。
而我们知道,数据的存储结构只有两种方式:顺序存储结构和链式存储结构(像栈、队列、树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。
比如我们要新增或查找某个元素,我们通过把当前元素的关键字通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可以完成操作。
存储位置=f(关键字)
其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:
![9b9c01a89cb27e454b9ed0707ffc3b7c.png](https://i-blog.csdnimg.cn/blog_migrate/ab03f0ad8afc9e02d9c0f6bc193d0869.jpeg)
查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。
哈希冲突
然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计是至关重要的,好的哈希函数会尽可能的保证计算简单和散列地址分布均匀,但是,我们需要清除的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希宏图的解决方案有多重:开放地址法(发生冲突,继续寻找下一块被找用的存储地址),再散列函数法,链地址发,而HashMap即是采用了链地址法,也就是数组+链表的方式。
HashMap的实现原理
HashMap的主干类是一个Entity数组(jdk1.8),每个Entity都包含有一个键值对(key-value)
我们来看一下源码:
static class Node implements Map.Entry { final int hash; final K key; V value; Node next; Node(int hash, K key, V value, Node next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; }
![60e6cdf4cba3fa0c8c18a7199224d9a5.png](https://i-blog.csdnimg.cn/blog_migrate/c488719ac31164f0c28fabe5edb05f34.jpeg)
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度是O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap的链表出现越少,性能才会越好。
其他几个重要字段。
/** * The number of key-value mappings contained in this map. */ transient int size;/** * The number of times this HashMap has been structurally modified * Structural modifications are those that change the number of mappings in * the HashMap or otherwise modify its internal structure (e.g., * rehash). This field is used to make iterators on Collection-views of * the HashMap fail-fast. (See ConcurrentModificationException). */ transient int modCount;
HashMap有4个构造器,其他构造器如果用户没有传入initialCapacityhe LoadFactory这两个参数,会使用默认值initialCapacity默认为16,loadFactory默认为0.75
我们可能其中一个
public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }
从上面这段代码我们可以看出,在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组。
我们接下来再看看put操作的实现吧
public V put(K key, V value) { //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=16) if (table == EMPTY_TABLE) { inflateTable(threshold); } //如果key为null,存储位置为table[0]或table[0]的冲突链上 if (key == null) return putForNullKey(value); int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀 int i = indexFor(hash, table.length);//获取在table中的实际位置 for (Entry e = table[i]; e != null; e = e.next) { //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败 addEntry(hash, key, value, i);//新增一个entry return null; }
get方法通过key值返回对应value,如果key为null,直接去table[0]处检索。我们再看一下getEntity这个方法通过key值返回对应的value,如果key为null,直接去table[0]处检索。我们再看一下getEntry这个方法。
可以看出,get方法的实现相对简单,key(hashCode)---->hash--->indexFor--->最终索引位置,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。要注意的是,有人觉得上面在定位到数组位置之后然后遍历链表的时候,e.hash == hash这个判断没必要,仅通过equals判断就可以。其实不然,试想一下,如果传入的key对象重写了equals方法却没有重写hashCode,而恰巧此对象定位到这个数组位置,如果仅仅用equals判断可能是相等的,但其hashCode和当前对象不一致,这种情况,根据Object的hashCode的约定,不能返回当前对象,而应该返回null,后面的例子会做出进一步解释。