HashMap实现原理

时间:2022-04-08 11:47:26

学习笔记之HashMap篇,简单学习了解HashMap的实现原理和扩容。

大家都知道HashMap处理数据很快,时间复杂度O(1),那么是怎么做到的呢?那就先了解一下常见数据结构。

一般来说,我们把存储结构分为两种个,顺序存储结构和链式存储结构,那我们就以最常见的两种,数组和链表为例。

数组

数组采用的一段连续的存储单元来存储数据,我们可以通过数组的下表来进行查找数据,时间复杂度为O(1),如果通过给定的值来查找需要遍历数组,所以时间复杂度为O(n),当然在有序的情况下我们可以加速这个对比过程,通过二分查找可以实现时间复杂度O(logn),插入删除元素的话需要一个一个处理元素位置,所以也是O(n)级别。

链表

链表的话存储数据不需要连续的存储单元,只需要在当前数据中存储下一个来实现链表,这样我们无法像数组那样通过下表查找,在查找数据时只能一个一个往后找,所以时间复杂度为O(n),但是链表的优势就在于插入删除操作只需要处理一下结点的引用就可以了,所以时间复杂度O(1)。

既然数组和链表各有优势,那我们能不能结合他们的优势呢?哈希表应运而生。

哈希表简单介绍

上面说到了数组可以通过下标查找数据,时间复杂度O(1),哈希表就利用这个优势,所以哈希表的主干就是一个数组,那么问题来了,我知道下标才能快速取啊,然而我现在只有值,怎么通过存储元素的值来确定他的下标呢?这里,我们就要通过哈希函数来把这个元素值映射到对应的下标,至于这个函数,我们就不详细介绍了,简单来说就是取这个元素值的哈希值来做模运算从而获得下表位置,通过这个位置来实现快速读取。说到了哈希函数,这个函数的设计尤为关键,直接影响到性能,因为这个函数设计的不好,可能导致很多数存储在了同一个下标下。那么看到这又该发现问题了,一个下标下怎么存储很多元素呢?这便是哈希冲突的问题。

哈希冲突

正如上面所讲,哈希冲突就是我们在通过哈希函数来计算下标的时候出现了重复,当一个元素要存进去的时候发现里面已经被占了,这便是哈希冲突,也被叫做哈希碰撞。我们前面也说过了,数组需要连续的存储单元来存储数据,所以再好的哈希函数的设计也不可能做到不出现哈希冲突,所以就出现了几种解决哈希冲突的方法:开放定址法,在散列函数法,还有链地址法。

我们的HashMap使用的就是链地址法,也就是主干为一个数组,而在每个位置上存放的又是一个链表,这实际上就是一个链表散列的数据结构。这也就是前面我说哈希函数设计的好坏直接影响性能的一个原因,哈希函数的设计原则是要做到计算简单和散列地址分布均匀,分布不均匀导致的结果就是一个位置上出现一个很长的链表,我们找到这个位置再去寻找数据的时候有需要遍历链表上的数据来寻找,这就导致了读取数据的性能下降。

HashMap的实现原理

下面我们一起看一点源码。

 public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable

先来看定义,HashMap类继承了AbstractMap类,实现了Map,Cloneable和Serializable接口。

     /**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30; /**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

这里看定义的几个常量,DEFAULT_INITIAL_CAPACITY是默认初始容量16,MAXIMUM_CAPACITY最大容量2的30次方,DEFAULT_LOAD_FACTOR默认加载因子0.75。

     /**
* The number of key-value mappings contained in this map.
*/
transient int size; /**
* The next size value at which to resize (capacity * load factor).
* @serial
*/
// If table == EMPTY_TABLE then this is the initial capacity at which the
// table will be created when inflated.
int threshold; /**
* The load factor for the hash table.
*
* @serial
*/
final float loadFactor; /**
* 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;

再来看几个比较重要的变量:

size是指当前哈希表中键值对的数量,源码中还有size()方法来返回了这个变量size。

threshold在源码中的注释为The next size value at which to resize (capacity * load factor).如果我没理解错的话threshold是指下一个要进行扩容的值,通常是容量*加载因子。

loadFactor就是哈希表的加载因子。

modCount是用来快速失败的一个值,因为HashMap不是线程安全的,所以当多个线程导致了HashMap内部结构发生改变时,需要抛出异常。

     static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash; /**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
} public final K getKey() {
return key;
} public final V getValue() {
return value;
} public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
} public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
} public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
} public final String toString() {
return getKey() + "=" + getValue();
} /**
* This method is invoked whenever the value in an entry is
* overwritten by an invocation of put(k,v) for a key k that's already
* in the HashMap.
*/
void recordAccess(HashMap<K,V> m) {
} /**
* This method is invoked whenever the entry is
* removed from the table.
*/
void recordRemoval(HashMap<K,V> m) {
}
}

这一部分代码很长,这是HashMap中的一个内部类Entry,前面我们也说到HashMap是数组加链表的结构,主干数组上每个位置就是Entry,Entry就是HashMap中的一个基本组成元素。

这段源码中后面的类中的方法就不详细研究了,看一下2-5行定义的几个变量,首先是key和value,每个Entry中就是一个key-value键值对。

第4行next,这个next存储就是指向下一个Entry的引用,就是通过这个next形成了一个单链表的结构,进而形成了主干数组上放链表的HashMap的结构。

第5行hash,对key的hashcode值进行hash运算后得到。

 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

这一行代码就是HashMap的主干数组。

下面看一下HashMap的存取

HashMap中的存取

     public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue();
} private V getForNullKey() {
if (size == 0) {
return null;
}
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}

首先是get方法,当key等于null的时候,调用getForNullKey方法。那我们先来看getForNullKey方法,首先判断当前HashMap的当前元素数量,如果为0返回null,否则的话先定位到主干数组下标为0的位置,然后遍历Entry链表,一个一个找key为null的那一个,如果有,返回对应的value值,如果没有,返回null。

那当key不是null的时候,调用了一个getEntry的方法,源码如下

     final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
} int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}

和上面的getForNullKey方法很相似,先看size是否为0,然后用hash方法得到hash,然后通过indexFor的方法传入hash和数组长度得到这个key所存储的下标位置,然后遍历Entry数组,寻找那个要找的key,返回这个Entry,如果没有则返回null。

再回到上面代码,得到这个Entry以后再返回他的value。

这其中一些细节是没有深入研究的,先明白大体过程,慢慢深入了解细节。

下面看put方法。

     public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
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++;
addEntry(hash, key, value, i);
return null;
} /**
* Offloaded version of put for null keys
*/
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}

首先判断数组是否{}空数组,如果是的话,进行数组填充为数组分配实际存储空间,如果要存储的键值对中key为null,调用putForNullKey方法,大体操作过程就是在下标为0的地方遍历该位置上的Entry链表,如果发现已经存在null这个key,那就覆盖掉以前的value,如果没有,那就创建一个新的Entry接在链表上。

同样的,如果不为null进行的操作也基本类似,获得hash,通过hash和数组长度获取下标,定位到下标对应的Entry链表遍历对比,已经存在就覆盖,没有就创建新的接在后面。

HashMap的扩容

首先得知道什么时候扩容,就是当当前size达到阈值,也就是前面提到的threshold,容量*加载因子时,就要自动扩容了。

这里要提到的就是resize,重新计算容量,当我们不停地向HashMap中添加元素时,HashMap内部数组无法装下更多元素,就需要扩大数组的长度来装更多的元素,当然,数组无法扩容,所以我们使用的方法就是用一个更大的数组来代替小的数组。

     void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
} Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

先上resize方法的源码,先引用扩容前的数组,得到长度,然后第4行的if,是判断扩容前的数组是否已经达到最大值MAXIMUM_CAPACITY,也就是2的30次方,如果已经到了,那就修改阈值为Integer.MAX_VALUE,也就是int的最大值2的31次方减1,这样以后就不在扩容了。如果还没达到最大值,那先new一个新的Entry数组,调用transfer方法将数据放入新数组,然后将HashMap中的table属性引用这个新数组,然后得到新的阈值。

这里使用了transfer方法来拷贝,下面看一下这个方法。

     void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}

transfer方法首先引用了旧的Entry数组,遍历这个旧数组,每循环一次,用Entry对象e获取到这个元素,并且将数组中该位置的Entry对象的引用释放,然后嵌套了一个do-while循环遍历链表,当前上的元素重新计算在新数组中对应的下标,断开与后一个之间的连接,指向目标位置(这样的结果就是发生哈希冲突时元素往同一个下标上位置放的时候会插入到链表头,先放的会放到尾部),然后将该元素放在数组上,e再指向next,直到把链表中的每个元素重新分配,然后外层循环继续循环到旧数组的下一个下标处。

看起来很乱,通俗点讲就是俩循环遍历了数组上的每个链表上的每个元素,重新计算了他们在新数组的位置并且挪过去。这就是一个rehash再散列的过程。

这一篇就到这,大佬们的支持是我努力学习的动力,哪里有问题请多帮我指正。