HashMap大家应该都知道,有什么特点,怎么存储数据,底层数据结构,JDK1.7数组+链表,JDK1.8数组+链表+红黑树。今天来深刻学习一下HashMap源码。
HashMap中的数组(桶)
看一下源码中怎么定义它的
Node数组也叫做桶
transient Node<K,V>[] table;
定义了一个Node类型的数组对象。
再看一下它的构造方法
创建桶
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);
}
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
一般情况下我们在new HashMap对象时,没有传参,那么这里的initialCapacity就有了一个默认的值,loadFactor也一样。观察源码发现默认容量为1<<4即16,那么我们得到一个结论HashMap的默认大小是16,默认负载因子0.75f;
桶的大小
下面来看一下tableSzieFor()方法
假如我们给定了一个初始容量桶的大小(12),这段代码就会把这个值转换成2的幂
,就会将桶的值变化到16;
static final int tableSizeFor(int cap) {
int n = cap - 1; n=12
n |= n >>> 1; n=0001100|0000110=0001110
n |= n >>> 2; n=0001110|0000111=0001111
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
如果n大于0并且n没有到最大大小,就返回n+1
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
此时就知道了桶的容量最大为 MAXIMUM_CAPACITY=1<<30 即2的30次幂
在HashMap中,桶的个数总是2的n次幂。
桶的扩容
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;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
如果旧容量没有达到最大容量,且大于默认初始容量(16)就继续进行移位运算向左移动一位,即之后扩容阈值扩大为之前的2倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
如果旧容量为0且旧容量扩容门槛大于0,则把旧门槛赋值给辛容量
newCap = oldThr;
else { // zero initial threshold signifies using defaults
调用默认构造方法时,旧容量,旧扩容门槛都是0,将使用默认值
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];
...
}
根据桶扩容的代码,我们可以知道
- 如果使用的是默认构造方法,初始化对象时,会将桶的容量赋值为16,即默认初始容量16,负载因子设置为0.75,扩容时,扩容门槛也会创建一个值来保存即threshold=容量*负载因子(12)
- 如果使用的是非默认构造方法,则初始化容量等于扩容门槛,扩容门槛在构造方法里等于传入容量向上最近的2的那次幂
- 如果旧容量大于0,则新容量等于旧容量的2倍,最大不超过2的30次幂,新扩容门槛为旧门槛的2倍。
HashMap中的链表
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
可以看到这个链表就是以单链表的形式存放的数据,每一个节点都存放了<K/V>,链表是存放在桶中的,然后这里有一个hash值, 思考这个hash值到底干了什么?猜想,这里的hash值是为了帮助链表放入桶中的位置
int类型的hash值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到key的高16位hashCode值与整个hashCode异或,这样做是为了是hash值更加分散。
我们知道hashMap的查找时间复杂度为O(1),为什么那么快?
那先看一下hashMap是怎么添加的数据,看一下put具体实现的方法。
put方法具体的实现,putVal()方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
当桶中没有数据时,或者桶的长度等于0时,就先初始化一下容量。
n = (tab = resize()).length;
如果这个桶[(n - 1) & hash]中没有元素
if ((p = tab[i = (n - 1) & hash]) == null)
新建一个节点放在桶的第一个位置
tab[i] = newNode(hash, key, value, null);
(n-1)&hash确定链表在第几个桶,hash=(key.hashCode()^key.hashCode()>>>16),
当前容量-1与异或运算得到的hash值与运算确定。假如我们桶的长度为16。
n-1=10000-1=01111 之前异或运算的值为1010
01111&1010=00101 5那么确定这个元素存放在数组第5号位置
实际上这个算法就是取模运算,使用位运算的原因是因为位运算比直接取模运算快得多
HashMap在确定Node存放的数组位置是通过取模运算来确定桶的位置,桶的容量一直为2的n次幂保证了位与运算的准确性。
可知key不允许重复,计算出的hash值重复了怎么办。
hash相等时怎么办
我们继续看putval()方法是怎么做的
else {
桶中已经有元素存在了
Node<K,V> e; K k;
如果桶中第一个元素的key与待插入元素的key相同,保存到e中用于后续修改value值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
如果第一个元素是树节点,则调用树节点的putTreeVal插入元素
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
遍历这个桶中的链表。
for (int binCount = 0; ; ++binCount) {
遍历完还找不到相同的key,则说明该键值对不在这个集合中
if ((e = p.next) == null) {
新建一个节点,放在链表的末尾
p.next = newNode(hash, key, value, null);
如果插入新节点后链表的长度大于8,则判断是否需要树化,因为第一个元素没有添加到binCount中,所以这里-1
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
树化方法
treeifyBin(tab, hash);
break;
}
如果hey相等则退出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
如果找到了对应key的元素
if (e != null) { // existing mapping for key
记录旧值
V oldValue = e.value;
判断是否需要替换旧值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
根据上面的代码可以得出结论,当Key相等时,覆盖value,当hash值相等时,采用尾插法插入链表。在JDK1.8以前,插入算法是头插法,使用头插法插入数据,每次新插入的node都需要移位。JDK1.8对链表过长做了一个优化,都知道链表太长之后查询速度比较慢,所以将链表树化了,这里我们重新讨论一下HashMap扩容。
HashMap扩容时,存储的数据会发生怎样的变化
table = newTab;
如果旧数组不为空
if (oldTab != null) {
遍历旧数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
如果旧数组位置不为空,赋值给e
if ((e = oldTab[j]) != null) {
清空旧桶,便于GC
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
如果这个链表不止一个元素且不是一棵树
则分化成两条链表插入到新桶中去
比如原来容量为4 2、6、10、14这四个元素都在2号桶中
现在扩容成8 则2和10还是在2号桶,6和15就要搬移到6号桶中去
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
(e.hash&oldCap)==0的元素放到低位链表中
如2&4==0
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
(e.hash&oldCap)!=0的元素放在高位链表中
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;
}
链表的迁移到此为止就全部完成了,每次扩容都需要遍历所有元素,在桶只有一个元素,迁移到新桶的位置不变,桶中是链表时,对链表进行分割,分割成两条链,满足低位链的条件元素的hash值与旧容量进行与操作等于0放到低位链,不等于0放到高位链,低位链直接放到原来桶的位置,高位链放到原位置+旧容量位置。
HashMap中的红黑树
红黑树是JDK1.8新加入的,当桶的数量大于64且单个桶中的元素大于8,树化,已经树化的,元素个数小于6,才会还原成链表
红黑树特点
红黑树本质上就是一颗二叉树,更确切的说,红黑树是一颗平衡二叉树。
红黑树的5种性质
- 节点是红色或者黑色
- 根节点是黑色的
- 每个叶子节点(空节点,NIL节点)是黑色的
- 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。(确保没有一条路径会比其他路径长出2倍。)
红黑树是相对接近平衡的二叉树
时间复杂度为O(log n)与树的高度成正比
红黑树在查找元素效率比链表高,但增删元素不如链表。
HashMap中红黑树定义
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;
上面已经知道什么时候树化,我们去putVal()方法查看发现树化的方法是treeifyBin()
树化
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
如果桶为空桶或者桶的大小小于MIN_TREEIFY_CAPACITY = 64,就扩容,不进行树化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
如果插入元素桶的位置不为空,就把当前桶的所有节点全部换成树节点
else if ((e = tab[index = (n - 1) & hash]) != null)
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
链表最后一个元素为空,表示节点已经全部换成了树节点,然后树化。
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
到这里为止树化大致上也完成了。
。。。。。。。。。。。。。。。
。。。。。。。。。。。。。。。
总结
在JKD1.8,HashMap的底层实现就是数组+链表+红黑树的结构
Node数组
HashMap默认初始容量为1<<4 ,默认负载因子为0.75f,容量总是2的n次幂;
HashMap扩容是键值对数=容量*负载因子时扩容,2倍容量
链表
HashMap在链表添加元素是采用尾插法
HashMap链表的扩容是采用高低位分割链表一次性迁移到新桶
红黑树
HashMap桶数量需要大于64单个桶中链表元素个数超过8才会树化
HashMap树化之后,树的元素个数小于6才会反树化