HashMap
一、HashMap是什么?
hashmap是基于哈希表的map接口的实现,允许使用null值和null键。
二、哈希表是什么?
在讨论哈希表之前,先要了解各种数据结构:数组、链表、二叉树、哈希表、红黑树、…
2.1 各种数据结构
2.1.1 数组
采用一段连续的存储单元来存储数据,对于指定下标的查找,时间复杂度(O是描述算法的运行时间与输入数据个数之间的关系)为O(1),通过给定的值来查找,就需要遍历整个数组,时间复杂度为O(n),其中无序数组的插入是直接插入到末尾,所以其时间复杂度为O(1);对于有序数组来说,可通过二分查找,将定值查找的时间复杂度降为O(logn),但删除和插入的时间复杂度为O(n),因为涉及了数组元素的移动,两者的时间复杂度相加为O(n)。
2.1.2 链表
链表中每个数据的存储都是一个节点,一个节点内包括数据域和指针域,数据域用来存储该节点的数据,而指针域用来存储指向下一个节点的地址。
链表的插入和删除(找到指定操作位置的时间复杂度为O(1))
其时间复杂度为O(n),但是链表的查询需要遍历整个链表,其时间复杂度为O(n)。
2.1.3 二叉树
对一棵相对平衡的二叉树(平衡树),其查找、删除和插入的时间复杂度都为O(logn),但最坏的情况下,二叉树所有的时间复杂度都为O(n)。
2.1.4 哈希表
哈希表的时间复杂度
哈希表的插入、删除和查找的时间复杂度都为O(1)。
相比以上几种数据结构来说,哈希表的效率很高,不考虑哈希冲突的情况下,时间复杂度都为O(1)。
哈希函数
数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构。在数组中通过下标查找某个元素时,只要通过一次定位就能达到,而哈希表的主干就是数组。
我们要查找某个元素,先要通过关键字查询到对应的下标位置,
所以引入了哈希函数,根据不同的情况可以设计不同的哈希函数,不一定要拘泥于某种哈希函数。
i
n
d
e
x
=
f
(
K
e
y
w
o
r
d
s
)
index = f(Keywords)
index=f(Keywords)
其中index代表了数组的下标,而Keywords则是查询的关键字。
哈希函数设计的好坏直接影响了数据查找和插入的效率。
哈希冲突
哈希冲突就是对某个元素进行哈希运算时,得到的存储地址已经被其他元素所占用了。所以哈希函数的设计至关重要,好的哈希函数能够使计算简单和散列地址分布均匀,因为数组是一块固定长度的内存空间,哈希函数设计得再好也不可能没有哈希冲突,hashmap中采用了链地址法来解决哈希冲突,即数组+链表的方式。
三、HashMap的数据结构
你可能注意到:在HashMap数组的位置0存的数据的key为null(当然位置0的key值也可以不为null,它是由hash决定的),实际上当key=null,hash=0,所以key=null时put方法是存到数组的第0位的。
HashMap底层采用了数组+链表的数据结构,这样操作起来的效率高,速度快。链表是为了解决哈希冲突的问题,链表在插入数据时,先要遍历整个链表,如果链表存在该key值,就覆盖掉,否则就新增,插入的时间复杂度为O(n),在查找时,需要遍历整个链表,其时间复杂度为O(n)。因此,链表不宜过长,否则会影响查找速率。
存储数据的对象节点实际上是实现了Map.Entry对象接口,Node对象包括了四个属性。
// HashMap的主干是Entry<K,V>类型的数组,Node是HashMap的基本组成单元,每一个Node都包括一个键值对。
// 储存对象Node是HashMap中的一个静态内部类
static class Node<K,V> implements Map.Entry<K,V>{
final int hash; // 对key的hashcode进行hash运算得到的值
final K key;
V value;
Node<K,V> next; // 储存指向下一个Node对象的引用,出现哈希冲突时,该数组元素会出现链表结构,会使用next指向链表的下一个元素对象。
// 全参构造函数
Node(int hash, K key, V value, Node<K,V> 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; }
public final String toString(){ return key + "=" + value; }
// Node对象的hashCode方法,返回的是键值得hashCode值得异或值
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
// Value的setter方法,将新值赋值给value,并返回旧值
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
// 重写的equals方法
public final boolean equals(Object o) {
// 判断两个对象的引用是否相同
if (o == this)
return true; // 如果相同,返回true
if (o instanceof Map.Entry) { // 首先判断对象o是否是Map.Entry的类型
Map.Entry<?,?> e = (Map.Entry<?,?>)o; // 将对象o强制转换成Map.Entry类型
if (Objects.equals(key, e.getKey()) && // 将对象o中的key和value和原来比较,如果两者都相同就返回true。
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
hash方法,如下图所示:
static final int hash(Object key){
int h;
// key的hashcode值异或key的hashcode值右移16位的值得到最终的hash值
// 这样是为了让高位也能参与hash运算,让hash值更加分布的更加均匀
return (key == null) ? 0 : (h = key.hashcode()) ^ (h >>> 16);
}
四、HashMap的初始化操作
4.1 为什么在jdk1.8中要引入红黑树?
原因:虽然有链表可以解决hash冲突,但是随着数据的增长,hash冲突越来越频繁,链表长度会变得很长,HashMap中的get方法在查找元素时是通过遍历链表查找对应的数据,所以链表数据过长会导致get方法的效率越来越低。
红黑树:在JDK1.8时,引入了红黑树的结构,当链表的长度大于等于8时,链表会自动转换为红黑树,此时查询链表的时间复杂度为O(logn),提高了效率。
注意:当红黑树中的元素个数 <= 6时,红黑树会转回链表结构,这个临界值为什么不是7呢?这是为了避免在使用时,在8这个临界值上一会删一个变成链表,一会加一个变成红黑树,浪费系统资源,损耗性能。所以让两个数之间隔了一个数,避免以上描述情况的发生。
4.2 HashMap中的各个参数
HashMap在通过new关键字进行实例化时,没有对HashMap进行初始化,仅仅是对各项数据进行了检查。在进行putVal操作时,才对数组进行了初始化操作。
4.2.1 构造方法
构造方法一:该构造方法能够指定初始容量和负载因子,负载因子越大,数组能够存放的元素越多,数据的散列性也就越差,负载因子是可以大于1的。
public HashMap(int initialCapacity, float loadFactor) {
// initialCapacity 初始容量 默认值为16
// loadFactor 负载因子 默认值为0.75
if (initialCapacity < 0) // 当初始容量小于0时,抛出一个非法参数异常("非法的初始容量:"+initialCapacity)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY) // 当初始容量>最大容量 (1 << 30) ,那么初始容量就等于 1<<30
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor)) // 当loadFactor <= 0 时,抛出非法参数异常
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
无参构造方法:两个参数都采用默认值,initialCapacity = 16 , loadFactory = 0.75
一个参数的构造方法(initialCapacity) 可以指定数组的长度,负载因子默认为0.75.
4.2.2 tableSizeFor()方法:
// 该方法是对n最高位为1的部分,将其后所有的部分全部变成1.
static final int tableSizeFor(int cap) {
// cap-1是为了返回一个大于等于cap的最小的2的整数次幂
// 如果不减1,那么当cap=16时,最终返回的值为32,不符合
// 原来的期望,直接扩大一倍
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
// 这里是令返回值为2的整数次幂
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
HashMap的各种属性:
transient Node<K,V>[] table; // 由储存数据构成的数组,会将16的静态变量赋值给数组长度table.length
int threshold; // 允许储存的最大的元素数量,通过table.length*loadFactor得到
final float loadFactor; // 数据的增长因子,默认为0.75,在进行扩容时用到
int modCount; // 记录内部结构发生变化的次数,key相同时,覆盖值得操作不会计算入内
int size; // 实际储存的元素数量
4.2.3 为什么数组长度默认为16?
为了减少hash值得碰撞,需要实现一个尽量均匀分布的hash函数,在HashMap中通过利用key的hashcode值,来进行位运算得到hash值
公式
i
n
d
e
x
=
e
.
h
a
s
h
(
)
∗
(
n
e
w
C
a
p
−
1
)
index = e.hash()*(newCap-1)
index=e.hash()∗(newCap−1)
当newCap=16时,newCap-1=1111;如果hash后4位的值时均匀的,那么位与运算的结果也肯定时均匀的,如果newCap!=2的整数次幂,那么newCap-1 的二进制就不会全为1,这样的话,存在多种后4位不同的数据对应了相同的index,所以就会使得每种index出现的几率不一样,不符合hash均匀分布的原则。所以数组长度必须为2的整数次幂。
五、putVal()和get()方法的源码解读
5.1 putVal() 方法
HashMap中的put方法实际上是引用了putVal()方法。
// HashMap中的put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
putVal()方法:
// putVal()方法的参数说明:
// 1.hash(Key):键值的hash值
// 2.key: 键值
// 3.value
// 4.onlyIfAbsent
// 5.evict
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1 判断数组tab是否为空,如果是就进行扩容数组初始化
if ((tab = table) == null || (n = tab.length) == 0)
// 1.1 将初始化长度16赋值给n
n = (tab = resize()).length;
// 2.1 通过hash算法获取的节点为null, 就新建一个节点对象赋值给当前的位置
if ((p = tab[i = (n - 1) & hash]) == null) // 2.1.1 p节点是通过hash算出来的在数组中对应的元素
// 2.1.2 创建一个新节点(node的next为null)
tab[i] = newNode(hash, key, value, null);
// 2.2 p节点不为null
else {
// 定义一个e节点,和一个键值k
Node<K,V> e; K k;
// 2.2.1 判断put的key值与p节点的key值是否想等
if (p.hash == hash && // 注意:先判断hash值是否想等,如果hash值不想等,那么key值必然不相等
((k = p.key) == key || (key != null && key.equals(k))))
// 2.2.1.1 如果key值相等======>就将p节点赋值给定义好的节点e
e = p;
// 2.2.2 p节点如果是红黑树节点的话,就进行红黑树插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 2.2.3 p节点是普通的链表节点(且p节点的key与put的key不等)
else {
// 遍历单链表
for (int binCount = 0; ; ++binCount) {
// 2.2.3.1 将p节点的下一个节点赋值给e,判断下一个节点是否为null
if ((e = p.next) == null) {
// 1)如果为空,就将p.next 指向 新节点(hash,key,value,null)
p.next = newNode(hash, key, value, null);
// 2)如果链表长度>=8时,将链表转换成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 2.2.3.2 判断put的key值是否与e节点的key值想等
if (e.hash == hash && // 这里证明了put的key == 节点e的key
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 2.2.3.3 以上都不成立时,及时更新p节点(以便下一次的循环)
p = e;
}
}
// 2.2.4 如果存在e节点,就覆盖
if (e != null) { // existing mapping for key
// 2.2.4.1 把e节点的值赋值给oldValue
V oldValue = e.value;
// 2.2.4.2 判断是否允许覆盖,并且oldValue是否为空
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
// 2.2.4.3 返回旧值
return oldValue;
}
}
// 2.3 如果不是覆盖的操作,那么就更改操作次数
++modCount;
// 2.4 当size大于临界值 size: 指的是插入hashmap中键值对的数量 threshold = initialCapacity*loadFactory
if (++size > threshold)
// 2.5 将数组的大小设置为原来的2倍,并将原来数组中的元素放到新数组中
resize();
// 2.6 回调以允许LinkedHashMap后置操作
afterNodeInsertion(evict);
// 2.7 put成功,返回null
return null;
}
5.2 get方法
5.2.1 get方法
public V get(Object key) {
// 1.定义一个节点e
Node<K,V> e;
// 2.通过hash,key来查找hashmap中的某个节点,如果找到了就返回该节点的值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
5.2.2 getNode()方法
final Node<K,V> getNode(int hash, Object key) {
// 定义node节点数组tab,node节点first、e,tab的长度n,键值k
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 1.给tab赋值table != null && tab.length > 0 && 根据hash得到tab[index] != null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 1.1 比较key值是否想等
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
// 1.1.1 想等返回first节点
return first;
// 1.2 将first下一个节点的引用赋值给节点e != null
if ((e = first.next) != null) {
// 1.2.1 判断first节点是否为红黑树节点
if (first instanceof TreeNode)
// 1.2.2 是的话就用getTreeNode(hash,key)方法返回一个红黑树节点
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 1.2.2 循环,判断节点e的key值 ?= get的key
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 是的话就返回节点e
return e;
} while ((e = e.next) != null); // 令e = e.next 继续循环,直到找到,或者下一个节点为null为止
}
}
// 未找到节点,返回null
return null;
}
六、HashMap的扩容机制
6.1 resize()方法
// 扩容方法
final Node<K,V>[] resize() {
// 1.定义一个数组保存原来的链表数组
Node<K,V>[] oldTab = table;
// 2.获取旧数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 3.获取旧的扩容阀域值
int oldThr = threshold;
// 4.定义新的数组长度和扩容阀域值
int newCap, newThr = 0;
// 扩容
// 5.当旧的数组长度大于0时
if (oldCap > 0) {
// 5.1 且 数组长度 大于 1<<32 是
if (oldCap >= MAXIMUM_CAPACITY) {
// 5.1.1 令HashMap中的扩容阀域值=Integer.MAX_VALUE
threshold = Integer.MAX_VALUE;
// 5.1.2 同时返回旧数组
return oldTab;
}
// 5.2 旧数组的2倍 < 1<<32 而且 旧数组的长度大于等于16时
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 5.2.1 令新的扩容阀域值变成原来的2倍
newThr = oldThr << 1; // double threshold
}
// 有参的构造方法
// 情况一:已经有了table数组,正在扩容,数组长度小于16
// 情况二:使用有参构造HashMap,正在第一次使用put方法
// 6.旧的扩容阀域值大于0时
else if (oldThr > 0) // initial capacity was placed in threshold
// 6.1 使新的数组长度等于旧的扩容阀域值
newCap = oldThr;
// 7.当旧的数组长度和扩容阀域值都为0时
// 无参构造方法
else { // zero initial threshold signifies using defaults
// 7.1 定义初始数组长度为16
newCap = DEFAULT_INITIAL_CAPACITY;
// 7.2 扩容阀域值为16*0.75=12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 8.新的扩容阀域值等于0时
if (newThr == 0) {
// 8.1 定义ft = 数组长度*0.75
float ft = (float)newCap * loadFactor;
// 8.2 给新的扩容阀域值赋值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 9.给HashMap内的扩容阀域值=newThr
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;
}
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;
}
在JDK1.7中,resize时,index需要重新经过一个hash算法得到,JDK1.8对index的获取做了一些调整。
以前确定index是用e.hash&(oldCap-1),是取余重新hash,现在不用了,我在扩容是将数组的长度拓展为原来的2倍,所以元素要么在原位置,要么在移动的2次幂的位置,所以我们只需要看新增的hash值是0还是1,如果是0,那么在原位置没有变化,如果是1那么就在原来的基础上index + oldCap。
声明:hashMap底层原理网络上已有很多资源,我也是从网上东抄西补才写完了这篇博客,有很多地方可能没有理解到位请多多包涵,我会将查询信息的网址放在下文:
链接1:jdk1.7hashMap底层原理分析
链接2:jdk1.8hashMap底层原理