声明:以下内容均基于JDK1.8
HashMap类图
HashMap是java.util包下的Map接口下的一个子类,也是我们常用的容器,是一个支持自动扩容、无序的key-value映射容器。
- AbstractMap抽象类:Map接口下的默认实现抽象类,实现一些Map通用的方法。
- Cloneable接口:实现对象拷贝必须要实现的接口。
- Serializable接口:实现序列化必须要实现的接口。
HashMap属性
HashMap的属性如上所示,下面一一分析:
serialVersionUID
private static final long serialVersionUID = 362498820763181265L;
序列化ID
DEFAULT_INITIAL_CAPACITY
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
默认初始容量大小,1右移4位,即16
MAXIMUM_CAPACITY
static final int MAXIMUM_CAPACITY = 1 << 30;
最大可以扩容的容量,即数组的最大长度,1右移30位
DEFAULT_LOAD_FACTOR
static final float DEFAULT_LOAD_FACTOR = 0.75f;
默认负载因子0.75
TREEIFY_THRESHOLD
static final int TREEIFY_THRESHOLD = 8;
树化临界点,在一个数组槽里面,链表长度超过8个会进行树化,将链表转换为红黑树
UNTREEIFY_THRESHOLD
static final int UNTREEIFY_THRESHOLD = 6;
反树化临界点,在一个数组槽里,当链表长度低于6个时,会将红黑树重新转换为链表,之所以留下两个空位是避免频繁树化和反树化消耗性能
MIN_TREEIFY_CAPACITY
static final int MIN_TREEIFY_CAPACITY = 64;
最小树化容量,想要树化,除了需要满足之前的链表长度超过8的条件之外,还需要满足此时数组长度至少64。如果数组长度小于64,他会认为并非数据量大导致Hash冲突变多,而是认为存储的数据有问题,导致Hash冲突过大,并不会选择转换红黑树而是扩大容量。
table
transient Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
一个Node数组,用来存储数据的地方,Node是HashMap定义的内部类,一个典型的单链表结点,里面保存了key、value、key的hash值和后指针。
entrySet
transient Set<Map.Entry<K,V>> entrySet;
final class EntrySet extends AbstractSet<Map.Entry<K,V>>
作为entry()的缓存,Entry是Map接口定义的一个接口,HashMap实现了该接口,里面保存一个键值对,以及实现了一些通用方法。
size
transient int size;
当前容器里元素的个数
modCount
transient int modCount;
修改次数,用于迭代器迭代时执行快速失败策略
threshold
int threshold;
当数组中的元素达到多少时进行扩容,threshold = capacity * loadFactor
loadFactor
final float loadFactor;
负载因子
TreeNode
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
HashMap的内部类,继承于LinkedHashMap的Entry,是一个典型的树型节点,里面保存了父节点、左右孩子和prev指针,prev是链表中的节点,用于删除元素时可以快速找到它的前置节点。
LinkedHashMap的Entry是一个典型的双链表,拥有前后指针。
HashMap常用API
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
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);
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
HashMap的构造函数
- 空参:使用默认的初始容量16和默认负载因子0.75
- 一个参数:可以指定初始化容量大小,负载因子还是用默认的0.75
- 两个参数:可以指定初始化容量大小和负载因子
初始化步骤:
- 判断传入参数合理性
- 初始容量小于0则抛异常,大于最大值则使用最大容量
- 负载因子小于0或者非数字则抛出异常
- 将传入负载因子赋值给loadFactor属性
- 调用tableSizeFor方法,将传入的初始化容量变为2的n次方(具体就是将数字先减一,然后通过右移和或运算全部变为1,然后再加上1就可以变成1后面全是0,也就是2的n次方,之所以将容量设置为2的n次方,原因是在将元素的hashCode转换为数组的下标时,用的用非取模,而是与操作,只有容量为2的n次方,才能用与操作替代取模)
注意:
- 在构造方法中,我们发现并没有进行数组的初始化,只是进行了相关属性的赋值操作,HashMap采用了懒加载策略,具体的初始化操作是在第一次调用put方法的时候。
- 刚才我们介绍了threshold属性,说他是容器扩容的临界值,等于capacity * loadFactor,但此时却直接将容器的容量赋值给了他,并没有乘负载因子,其实在第一次调用put方法时会重新计算。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab:内部数组
// p:hash对应的索引位中的首节点
// n:内部数组的长度
// i:hash对应的索引位
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
// 首次put时,内部数组为空,扩充数组。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算数组索引,获取该索引位置的首节点,如果为null,添加一个新的节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
HashMap.Node<K,V> e; K k;
// 如果首节点的key和要存入的key相同,那么直接覆盖value的值。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果首节点是红黑树的,将键值对插添加到红黑树
else if (p instanceof HashMap.TreeNode)
e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 此时首节点为链表,如果链表中存在该键值对,直接覆盖value。
// 如果不存在,则在末端插入键值对。然后判断链表是否大于等于7,尝试转换成红黑树。
// 注意此处使用“尝试”,因为在treeifyBin方法中还会判断当前数组容量是否到达64,
// 否则会放弃次此转换,优先扩充数组容量。
else {
// 走到这里,hash碰撞了。检查链表中是否包含key,或将键值对添加到链表末尾
for (int binCount = 0; ; ++binCount) {
// p.next == null,到达链表末尾,添加新节点,如果长度足够,转换成树结构。
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 检查链表中是否已经包含key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 覆盖value的方法。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount; // fail-fast机制
// 如果元素个数大于阈值,扩充数组。
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
final HashMap.Node<K,V>[] resize() {
HashMap.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的N次方)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 如果数组还没创建,但是已经指定了threshold(这种情况是带参构造创建的对象),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);
}
// 可能是上面newThr = oldThr << 1时,最高位被移除了,变为0。
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"})
HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
table = newTab;
// 下面代码是将原来数组的元素转移到新数组中。问题在于,数组长度发生变化。
// 那么通过hash%数组长度计算的索引也将和原来的不同。
// jdk 1.7中是通过重新计算每个元素的索引,重新存入新的数组,称为rehash操作。
// 这也是hashMap无序性的原因之一。而现在jdk 1.8对此做了优化,非常的巧妙。
if (oldTab != null) {
// 遍历原数组
for (int j = 0; j < oldCap; ++j) {
// 取出首节点
HashMap.Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 如果链表只有一个节点,那么直接重新计算索引存入新数组。
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果该节点是红黑树,执行split方法,和链表类似的处理。
else if (e instanceof HashMap.TreeNode)
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 此时节点是链表
else { // preserve order
// loHead,loTail为原链表的节点,索引不变。
HashMap.Node<K,V> loHead = null, loTail = null;
// hiHeadm, hiTail为新链表节点,原索引 + 原数组长度。
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
// 遍历链表
do {
next = e.next;
// 新增bit为0的节点,存入原链表。
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 新增bit为1的节点,存入新链表。
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;
}
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果当前数组容量太小(小于64),放弃转换,扩充数组。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
} else if ((e = tab[index = (n - 1) & hash]) != null) {
// 将链表转成红黑树,略
}
}
put方法:向HashMap里插入一个元素。
put方法底层会调用putVal方法去执行操作,传入的参数如下:
- hash(key):将key进行hash处理
- key:元素的键
- value:元素的值
- onlyIfAbsent:当存入键值对时,如果该key已存在,是否覆盖它的value。false为覆盖,true为不覆盖。 参考putIfAbsent()方法。
- evict:用于子类LinkedHashMap。
HashMap对计算Key的HashCode值时,并非直接调用key的hashCode方法,而是自己定义了hash方法,hash方法的作用是将
hashCode进一步的混淆,增加其“随机度”,试图减少插入HashMap时的hash冲突,换句更专业的话来说就是提高离散性能。
- 首先先判断key是否为null,如果为null,则将hashCode置为0
- 否则,将key的hashCode右移16位然后进行与或运算,即将key的HashCode的高低16进行与或运算
那么,为什么将高低16位进行异或运算能减少hash冲突?
这和putVal方法中将HashCode转换为数组索引的方法有关,他的转换方法如下:
i = (n - 1) & hash
n是数组长度,hash就是通过hash()方法进行高低位异或运算得出来的hash值。
这个表达式就是hash值的取模运算,上面已经说过当除数数为2的次方时,可以用与运算提高性能。
那么我们想想,大多数情况下,内部数组的容量一般都不会很大,基本分布在16~256之间。所以一个32位的hashCode,一直都用最低的4到8位进行与运算,而高位几乎没有参与。
所以通过hash()方法,将hashCode高16位与低16位进行异或运算,能有效的提高离散性能。
putVal方法是执行具体的插入操作,具体如下:
- 首先判断是否是第一次插入元素,如果是,调用resize方法进行扩容,并将扩容后的容量大小返回
- 通过hash值计算数组索引,获取首节点
- 判断首节点是否为空,如果为空,则创建节点插入进去
- 如果不为空,那么有以下三种情况
- 判断首节点的key是否和传入的key重复,如果重复则覆盖之前的值
- 如果不重复,则判断首节点是否是红黑树节点,如果是,则调用putTreeVal方法添加节点
- 如果不是红黑树节点,则一定是链表节点,遍历链表,如果发现已经存在key,则覆盖之前的节点,如果没有,则创建节点添加在末尾,判断当前链表长度是否>=7,如果是,则进行树化(其实树化的还有一个条件是数组容量大于64,具体在treeifyBin方法的逻辑里,后面会分析)
resize方法是进行扩容相关的操作,具体如下:
- 首先判断是否是第一次put元素,如果是,则判断传入了哪些参数,然后进行相应的初始化,创建一个Node数组,并重新计算threshold
- 如果不是第一次put元素,则计算以前的容量和threshold,扩容后的容量为之前的容量左移一位,即以前容量的2倍,扩容完成后,更新threshold等相关参数,然后将之前数组里的数据转移到新数组里面去
扩充数组不单单只是让数组长度翻倍,将原数组中的元素直接存入新数组中这么简单。
因为元素的索引是通过hash&(n - 1)得到的,那么数组的长度由n变为2n,重新计算的索引就可能和原来的不一样了。
在jdk1.7中,是通过遍历每一个元素,每一个节点,重新计算他们的索引值,存入新的数组中,称为rehash操作。
而java1.8对此进行了一些优化,因为当数组长度是通过2的次方扩充的,那么会发现以下规律:
元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为数组的长度,图(a)表
示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1
是key1对应的哈希与高位运算结果。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此
resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中
rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8
不会倒置。
treeifyBin方法:在链表中插入元素大于等于7个时,会调用该方法。具体逻辑如下:
- 首先判断数组的长度,如果小于64,则取消树化,进行扩容
- 否则,进行树化,先将要传入的元素插入链表,然后调用treeify方法进行树化
因此,要触发树化,需要满足两个条件,数组长度大于等于64,并且链表长度大于等于7进行树化。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
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的value值。
get方法的逻辑很简单,定义一个Node节点用来接收getNote方法查询的结果,如果没有找到则返回null,否则返回该元素的value。
getNode方法:具体的元素操作。
- 首先通过hash值确定数组的槽位,拿到首节点,然后判断该位置是否为空,如果为空,直接返回null
- 如果不为空,判断该节点的key值是否和查找的key相等,如果相等,则就是要查找的元素,直接返回
- 如果不相等,则说明该节点并不是要查找的元素,存在hash冲突,判断首节点还有没有后续节点,如果没有,直接返回null
- 如果有,判断是树节点还是链表节点,如果是树节点,调用getTreeNode方法去查找,如果是链表,循环遍历查找
public boolean isEmpty() {
return size == 0;
}
isEmpty方法:判断容器是否为空。
判断容器里的元素个数是否为0。
public int size() {
return size;
}
size方法:获取容器中元素的数量。
返回容器里存储的元素个数。
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
remove方法:根据key移除容器中指定的元素。
定义一个Node节点用来接收removeNode方法的返回值,如果没有找到,则返回null,否则,返回移除的元素的值
removeNode方法:查找元素并移除的具体操作。
removeNode和之前的get方法逻辑类似,都是先判断数组是否有元素,如果有元素,则计算数组索引获取首节点,如果首节点key相等则返回,反之判断是树节点还是链表,进行相应的处理,如果没有找到,直接返回null,如果找到,则根据是树节点还是链表进行相应的删除操作,最后modcount++,size--。
public Collection<V> values() {
Collection<V> vs = values;
if (vs == null) {
vs = new Values();
values = vs;
}
return vs;
}
values方法:返回容器里所有的value集合。
Values是HashMap定义的一个内部类,用来保存所有的value属性。
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new KeySet();
keySet = ks;
}
return ks;
}
keySet方法:返回容器里所有的key集合。KeySet也是内部类,用来保存所有key集合。
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
entrySet方法:返回所有键值对。EntrySet也是内部类。
总结
- HashMap是一个key-value形式的容器,非线程安全。
- 默认初始容量16,负载因子0.75,可以调用构造函数指定,容量必须为2的n次方,每次扩容2倍。
- HashMap采用了懒加载策略,初始化时并不会创建一个数组,第一次put操作时会创建。
- HashMap利用了大量的移位、与或运算替代乘除取模运算来提高性能。
- HashMap将hashCode转换为数组索引时,并非用hashCode对数组长度N取模,而是根据规律,当N为2的n次方时,hashCode%N = hashCode & (N - 1),用与运算来代替取模,这也是HashMap容量必须为2的n次方的原因。同时,HashMap定义了hash函数,通过将hashCode的高低16位进行异或运算来对key的hashCode进一步混淆,降低冲突,之所以高低16异或是因为在计算数组索引时,是将hashCode&(N-1),而HashMap的容量一般为16~256之间,也就是4~8位,因此高位几乎都不参与运算,因此高低位异或后可以降低冲突。
- HashMap在JDK1.8时做了一些优化,一方面引入了红黑树,当链表的元素大于等于7个并且数组长度大于等于64时,会将链表转为红黑树。另一方面,在数组扩容时,以前HashMap会将旧数组遍历rehash放到新数组中,而JDK1.8中,由于数组都是2的n次方,扩容之后索引比之前多了一个高bit位,只需要计算高bit位是0还是1即可,如果是0则放到原下标,如果是1,则新下标位置为原下标加旧数组长度。