HashMap的原理在任何java面试中可以毫不夸张的说是被问到几率是最高的,很多拥有四五年工作经验的“老油条”可能也不能说明白其底层实现原理,今天我们就来把这个用的很多但是了解的很少的HashMap彻头彻尾的解析一遍。
在了解HashMap之前,首先我们要了解以下几个知识点
什么是Hash表?
什么是Hash算法?
什么是Hash冲突及Hash冲突的解决办法?
针对上面三个问题,是我们在了解HashMap原理之前必须了解的,它们是实现HashMap的基础。Hash表是一种数据结构,Hash表本质上还是一个数组,是一种特殊的线性表,它是通过Hash算法来决定数据在数组的存放位置的。举例说明Hash表的存取过程,Hash函数我们采用最简单的f(k)=k%4,数据集{5,6,7,8}
上面是简单的Hash表的存取过程,上面其实存在着一个问题就是如果我现在需要再向Hash表中添加一个数据9,你会发现通过Hash函数计算应该存放到下标为1的位置,但是现在下标为1的位置已经存在数据了,此时引出一个Hash表中的另一个概念-Hash冲突,也就是两个不同元素映射到同一个位置上的情况,这种情况肯定是不允许存在的,下面再继续讨论下几种常用的解决Hash冲突的解决办法:
(1)、链地址法。链地址法的基本思路是将出现Hash冲突的数据放在一个链表中,这样数组的每一个数据元素都可能是一个链表,而链表是一个可动态增长的线性表,这样从某种程度上可以解决Hash冲突的问题。继续用上面讲到例子,继续添加数据9之后的结构就变成下面的结构。
(2)、线性探测法。又称开放地址法,基本思路是在出现冲突的位置继续寻找下一个位置,如果下一个已经存在数据则继续寻找下一个位置,直到冲突解决。
(3)、再哈希法。再哈希法的基本思路就是同时定义多个Hash函数,如果出现冲突则换另一个Hash函数继续进行Hash运算,直到冲突解决。
介绍完Hash表和Hash函数及Hash冲突的相关特性之后,接下来正式进入文章的主题--HashMap的原理分析。这里注意jdk1.7和jdk1.8对HashMap的处理是有区别的,此篇文章我们先以jdk1.7 HashMap的原理进行分析。
在jdk1.7中HashMap采用数组加单链表的结构来存储数据,数据采用<key,value>的形式存储。HashMap的一个数据节点叫做Entry,其定义包括四个部分key,value,next,hash,其中key和value就是我们需要存储的数据,next是链表指向下一个节点的指针,hash是根据Hash函数对key进行hash运算得到的hash值,其源码定义如下:
另外在了解HashMap的原理之前我们首先需要了解几个概念:capacity容量是指HashMap中数组的大小(注意不是能够存储数据量大小,而是数组大小);loadFactor加载因子,随着HashMap数据量的不断增加,Hash冲突的概率会越来越大,添加和查找数据的成本会越来越高,为了解决这个问题,我们需要对HashMap进行扩容,而扩容的时机就是这个加载因子决定的,当HashMap的大小达到capacity*loadFactor的时候,HashMap会进行扩容,HashMap的默认容量是16,默认加载因子为0.75,threshold 就是Capacity*loadFactor的值,也就是HashMap的扩容临界值大小。
HashMap共有四个构造函数HashMap(int initialCapacity, float loadFactor),HashMap(int initialCapacity),HashMap(),HashMap(Map<? extends K, ? extends V> m)。
(1)、HashMap()。该构造函数默认容量为16,默认加载因子为0.75。
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
(2)、HashMap(int initialCapacity)。该构造函数指定一个初始容量并使用默认加载因子0.75。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
(3)、HashMap(int initialCapacity, float loadFactor)。该构造函数我们可以指定HashMap的初始容量跟加载因子。
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;
threshold = initialCapacity;
init();
}
(4)、HashMap(Map<? extends K, ? extends V> m)。该构造函数根据我们指定的Map对象来构造一个HashMap对象。
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
putAllForCreate(m);
}
熟悉完HashMap中的一些简单概念及构造方法之后,我们继续通过源码来深入了解HashMap的数据存取操作的原理。
(1)、添加数据。向HashMap中添加数据是一个相对比较复杂的过程,如果是第一次添加数据,会调用inflateTable(int toSize),此方法的主要作用就是创建一个大小为capacity的数组。如果key为null则调用putForNullKey(V value)方法添加数据,否则通过hash()函数计算key值对应的hash值,然后根据hash值确定数据在数组的位置下标i,最后遍历数组i下标的链表,如果存在key值相同的节点,则直接替换value并返回旧值;如果不存在key值相同的节点,则通过addEntry方法向链表中添加新数据节点(新数据节点添加到链表的首位置)。
public V put(K key, V value) {
//如果table是空,也就是第一次put数据的时候,通过inflateTable初始化
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
//添加key为空的数据
return putForNullKey(value);
//根据hash函数计算key的hash值并根据hash值确定在数组中的位置
int hash = hash(key);
int i = indexFor(hash, table.length);
//遍历i位置上链表的链表,如果存在key值相同的节点,则更新节点的value值
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++;
//如果链表中不存在key值相同的节点,则添加新节点
addEntry(hash, key, value, i);
return null;
}
inflateTable初始化方法首先通过roundUpToPowerOf2方法返回一个大于toSize的2的幂值作为创建数组的大小。
private void inflateTable(int toSize) {
// 返回大于toSize的2幂值,也就是当前HashMap的容量
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//新建一个capacity大小的数组
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
addEntry方法首先判断HashMap数据量是否达到扩容临界值并且数据存放数组的位置已经存在数据,此时会调resize方法进行扩容,扩容大小为原容量的2倍(size>threshold不一定会触发扩容机制,扩容的条件是两个,一个是size>threshold;一个是数组对应位置非空即已经存在数据,只有满足两个条件的时候才会扩容)。
void addEntry(int hash, K key, V value, int bucketIndex) {
//添加数据之前首先判断是否需要扩容,如果当前数据量大于临界值threshold并且插入的位置已经有数据,则需要扩容,扩容之后的容量大小为之前的2倍
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
//将新数据添加到数组中
createEntry(hash, key, value, bucketIndex);
}
resize方法是HashMap比较核心的一个方法,也是在很多面试中面试官会经常问到关于HashMap扩容原理问题的出处,扩容之前会判断当前容量是否已经达到最大值MAXIMUM_CAPACITY,如果达到容量最大值就不再继续扩容了。接下来用扩容后的容量新创建一个数组,再创建新数组之后,通过transfer方法将原数组中的所有数据key重新进行hash计算并放到新数组对应位置。
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];
//通过遍历原数组中的所有数据并重新计算hash值,然后将数据添加到新数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//新数组赋值为原数组对象,至此数组扩容正式完成
table = newTable;
//更新新的临界值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
createEntry方法主要是通过已确定的数组下标将新数据添加到链表当中去,并且是添加到链表表头的位置。
void createEntry(int hash, K key, V value, int bucketIndex) {
//新节点的next为原bucketIndex位置的数据,换句话说,也就是新节点添加到了bucketIndex链表的表头
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
(2)、根据key值获取数据。获取数据的操作结合着添加数据操作来看就比较容易理解,首先判断key值是否为null,如果为null则在数组第一个位置table[0](前面讲过key为null的数据放在了table[0]的位置)的位置,然后遍历table[0]位置的链表直到找到key值相等的节点。key值不为空的情况,首先根据key进行hash计算找到key值对应的数组下标,然后遍历链表找到key值相等的节点。
public V get(Object key) {
if (key == null)
//key为null时获取
return getForNullKey();
//key不为null时获取数据
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
if (size == 0) {
return null;
}
//根据上面添加数据的逻辑,key为null的数据都放在数组的第一个位置,遍历数组第一个位置的链表,找到key值为空的数据返回
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//获取key值的hash值
int hash = (key == null) ? 0 : hash(key);
//首先根据hash值获取数据的数组下标,然后遍历链表直到key值相等节点,返回节点value
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;
}
(3)、删除数据。删除数据查找目标数据的逻辑与(2)一致,不在赘述,当遍历找到目标数据节点之后需要删除节点,如果目标节点是首节点,这直接将目标节点的后继节点赋值给数组对应位置。如果目标节点不是首节点,则将目标节点的后继节点作为目标节点前驱节点的后继节点(可自行学习了解一下链表的删除)。
final Entry<K,V> removeEntryForKey(Object key) {
if (size == 0) {
return null;
}
//首先通过Hash函数找到对应key值对应的数组位置
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);
Entry<K,V> prev = table[i];
Entry<K,V> e = prev;
//遍历数组位置链表,找到key值对应的数据节点,然后改变
while (e != null) {
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
modCount++;
size--;
//如果是key值是首节点,直接将首节点的后继节点赋值给数组i位置
if (prev == e)
table[i] = next;
else
//如果key不是首节点,则将目标节点的后继节点作为目标节点前驱节点的后继节点(链表的删除)
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}
总结:HashMap是通过数组加链表的形式进行数据存储的,当存储数据量达到Capacity*loadFactor并且数组对应位置已经存在数据时需要对其进行扩容,扩容的中心思想就是重新创建一个之前两倍的数组,并对原数组数据重新进行Hash计算并添加到新数组中。
才疏学浅,码字不易,有总结不到位的请各路大神多多指教,欢迎交流!