HashMap概述
HashMap实现了Map接口,我们常用HashMap进行put和get操作读存键值对数据。下面介绍基于jdk1.8深入了解HashMap底层原理。
HashMap数据结构
HashMaop实际是一种“数组+链表”数据结构。在put操作中,通过内部定义的哈希算法找到数组下表,将数据直接放入此数组元素中,若通过哈希算法得到的该数组元素已经有了元素(俗称hash冲突,链表结构的出现的实际意义也就是为了解决hash冲突问题)。将会把这个数组元素上的链表进行遍历,将新的数据放到链表末尾。
HashMap中主要是通过Key的hashCode来计算hash值的,如果key的hashcode相同equals比较不同就会产生链表,如果hashcode相同equals比较相同就会替换key的value。在JDK1.8中如果链表的节点数大约8个将转化为红黑树存储。
存储数据的对象
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
我们从jdk1.8源代码看出存储对象Node实际是实现Map.Entry对象接口。
hash:通过hash算法的出来的值。hash值的算法我们看下HashMap源代码的实现
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
不同的数据类型的hashCode计算的方法不一样,我们看下String和Integer两种数据类型的hashCode算法
String.hashCode()
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
通过将字符串转换成char数组,使用公式s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1]进行计算得出最后的值。val[i]值是对应字符的ASCII值.在看到这里的时候,这里为什么使用了一个31作为相乘因子(能为啥,还不是为了性能考虑,那为什么使用31性能能得到优化呢),这里可以延伸讨论。
public static int hashCode(int value) {
return value;
}
直接返回值.
key:存储数据的key
value:存储数据的value
next:下一个数据,出现哈希冲突时,该数组元素会出现链表结构,会使用next指向链表中下一个元素对象
链表结构导致的问题
通过哈希算法从寻址上能够高效的找到对应的下表,但是随着数据的增长,哈希冲突碰撞过多。在寻找数据上,找到该链表,会通过遍历再寻找对应数据,如此将会使得get数据效率越来越低。
在jdk1.8中,链表元素数量大于等于8将会重组该链表结构形成为“红黑树结构”,这种结构使得在hash冲突碰撞过多情况下,get效率比链表的效率高很多。
transient Node<K,V>[] table;
int threshold;
final float loadFactor;
int modCount;
int size;
table:存储数组的变量,初始长度为16通过源代码看出在第一次进行resize扩容(java是静态语言,在定义数组初始化时,需要定义数组的长度,在map数据增长后,内部机制会进行重新定义一个数组做到扩容的操作)初始化时,会将默认静态变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
赋给数组长度进行初始化。
loadFactor:数据的增长因子,默认为0.75。在进行扩容操作会使用到。
threshold:允许的最大的存储的元素数量,通过length数组长度*loadFactor增长因子得出
modCount:记录内部结构发生变化的次数,put操作(覆盖值不计算)以及其他…
size:实际存储的元素数量
put的流程
/**
* 将指定值与此map中指定的键关联。如果键已经存在于map中,则替换键所关联的旧值。
*
* @param key 与指定值相关联的键。
* @param value 与指定键关联的值。
* @return 与键关联的上一个值,或null,如果没有键的映射。(A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent 如果为true,不改变已经存在的value
* @param evict 如果为false, 该表处于创建模式。
* @return 先前的值, 或者为null如果没有
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; //存放链表头结点的数组,数组的每个元素相当于一个桶,好像这个链表就存放于一个元素的空间中
Node<K,V> p; //存放于数组i处的节点
int n;//数组的长度
int i; //数组的下标值
//刚开始table是null或空的时候,调用resize()方法,初始化一个默认的table;为tab和n赋值,tab指向数组,n为数组的长度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//用(n - 1) & hash计算出插入点在数组的下标。如果插入点为null,将此节点存放于此
//否则就会发生碰撞,此时将桶的头结点赋给p
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);//键值对的next为空
else {
Node<K,V> e; //相当于一个temp,一个暂时存放键值对的节点
K k; //同上
//hash值相等,key对象也相等,那么就是更新这个键值对的value。为什么要两者都满足,因为根据不同key对象的hashCode计算出来的hash可能相等,所以还需要通过比较引用("==")或者比较对象("equals")的方式判断。
//你可能要说那可以直接比较key对象就行,因为key相同,hash肯定相同。我们根据hash不同(p.hash == hash为false),可以判断出不是同一个key,我们知道符号"&&"有短路功能,所以整体为false,不用每次都去比较key,提高了效率。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;//我们将数组的头结点p赋给e,此时还没有将e插入数组的桶中
//我们可以从下一个else中知道,一个桶只能存放8个节点,大于八个将转成红黑树存储。根据桶中的Entry数,判断p的类型是否是TreeNode类的实例
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);//如果是,使用putTreeVal方法插入Entry
//碰撞后,满足与桶中的第一个entry不等,且此时桶中Entry小于等于8个
else {
for (int binCount = 0; ; ++binCount) {//没有条件,通过break跳出循环
if ((e = p.next) == null) {//当p后没有节点时满足条件,此时桶中就一个Entry;或者此时p为桶中最后一个Entry
p.next = newNode(hash, key, value, null);//新建Entry链接上,此时e为null
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st//当桶中存放第8个元素时,将链表转换成红黑树。treeifyBin方法会在另一篇博文详解。
treeifyBin(tab, hash);
break;
}
//上面的if判断是否跟桶中的第一个Entry相等,而这个if是依次跟桶中的Entry比较
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;//如果此时桶中有多个Entry,执行完两个if后还没跳出循环,e=p.next,相当于p=p.next.继续循环.这个else最重要的一点是要理解---利用e,依次比较桶中的Entry.
}
}
if (e != null) { // existing mapping for key//e不等于null的条件是桶中存在相同的Entry提前跳出循环
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)//onlyIfAbsent默认为false,oldValue可以为null
e.value = value;//替换value
afterNodeAccess(e);//LinkedHashMap继承HashMap,此方法在LinkedHashMap中被重写.在这里没什么用,但是在LinkedHashMap中此处调用此方法移动节点到最后.
return oldValue;//存在相同Entry时返回oldValue.我原来使用put方法时没想到还有返回值,所以还是要看看源码
}
}
//HashMap继承Serializable,当HashMap被序列化时,transient变量不会被序列化
++modCount;//modCout是transient变量
//size也是transient变量,指Map中包含的键值对的数量。threshold默认为16*0.75
if (++size > threshold)//如果数组中的Entry大于等于length*0.75
resize();//调用resize方法将数组扩大为两倍
afterNodeInsertion(evict);//LinkedHashMap继承HashMap,此方法在LinkedHashMap中被重写.
return null;//默认返回空
}
调用put方法示例
下面通过使用例子介绍这个过程
HashMap<Integer, String> hashMap = new HashMap<Integer, String>(4, 0.75f);// 1
int a1 = 1;
int a2 = 2;
int a3 = 5;
System.out.println(String.valueOf(a1&3) + " " + String.valueOf(a2&3)+ " " + String.valueOf(a3&3));// 1 2 1 数组下标
hashMap.put(a1, "1");// 2
hashMap.put(a2, "2");// 3
hashMap.put(a3, "5");// 4
1、创建了一个HashMap对象,初始化initialCapacity为4,增长因子为0.75。threshold初始化为4
2、进行了第一次put,因为table为空,进行了第一次resize()扩容操作,数组进行初始化,默认为16. threshold变为3。同时通过hash算法(数组长度n-1 & hash)即为1。
3、第二次put操作,同时获取数组下标为2,此时数组下标为2当前没有数组元素,则直接创建数据元素放入
4、第三次put操作,得到数组下标为1已经有了一个数组元素。同时我们知道存储数据的Node对象中又一个next,则新的此时的数据元素放入上一个链表中next为空的Node中的next中。
结论:通过hash算法进行计算的出来的数组下标,有一定概率会导致hash冲突,那在一个数组元素中,存在hash值一样的key,key却不相等。为了解决这一个hash冲突问题,使用了链表结构进行处理。
HashMap扩容resize()
java是静态方法,在数组进行初始化时,必须给一个数组长度。HashMap定义默认的数组长度为16。条件满足元素size>允许的最大元素数量threshold。则进行扩容。一般来说,在put操作中,HashMap至少进行了一次扩容(第一次为初始化)。
我们在原有的示例加入如下
int a4 = 6;
hashMap.put(a4, "6");
threshold:允许的最大的存储的元素数量,通过length数组长度*loadFactor增长因子得出
放入了2:2的next中,此时size=4,threshold>3,条件满足size>threshold,进行扩容resize()操作
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超过最大限制,不进行扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 进行原始长度*2扩容
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 第一次初始化
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 第一次初始化
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 新的最大允许元素数量值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 新的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 遍历老数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 直接按照原始索引放入新数组中
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 遍历链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 放入原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
在新的结构中,将原始的数组下标为1和2链表元素均匀分布新数组的其他数组元素中。此间扩容的变化的过程如下
老数组长度为4,通过算法得出数据的下标1:1为1,5:5为1,2:2和6:6为2
1(1:1 == > 5:5)
2(2:2 == > 6:6)
在进行扩容操作是,数组元素链表中的第一个数组下标不会产生变化,在遍历链表其他元素中通过算法"e.hash & oldCap"!=0则将链表元素放入新数据数组下标为[原始数据下标+原始数据长度]
再次引用大神的图,便于理解扩容的数据移动变化
在这里插入图片描述
在扩容操作中,因无需重新计算hash值,同时均匀将链表冲突的元素均匀分布到新的数组中。这设计实在是巧妙。
get寻找数据
在final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
get方法比较简单,基本流程为通过key的hashCode和寻址算法得到数组下标,若数组元素中的key和hash相等,则直接返回。若不想等,同时存在链表元素,则遍历链表元素进行匹配。由于1.8引用了红黑树结构,在链表元素过多时,1.8的实现将比1.7在get和put操作上效率高上很多。