源码分析—HashMap
1. HashMap构造器
HashMap总共给我们提供了三个构造器来创建HashMap对象。
(1). 无参构造函数public HashMap()
其: 默认容量:16 默认的负载因子:0.75
无参构造函数源码如下:
static final float DEFAULT_LOAD_FACTOR = 0.75f;//加载因子
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
(2). 有参构造函数public HashMap(int initialCapacity,float loadFactor)
该构造函数,可以指定hashmap的初始化容量和负载因子,但是在hashmap底层不一定会初始化成我们传入的容量,而是会初始化成大于等于传入值的最小的2的幂次方,比如我们传入的是17,那么hashmap会初始化成32(2^5)。
那么hashmap是如何高效计算大于等于一个数的最小2的幂次方数的呢,源码如下:
static final int MAXIMUM_CAPACITY = 1 << 30;
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 tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
它的设计可以说很巧妙,其基本思想是如果一个二进制数低位全是1,那么这个数+1则肯定是一个2的幂次方数。举个例子看一下:
可以看到,它的计算过程是:首先将我们指定的那个数cap减1(减1的原因是,如果cap正好是一个2的幂次方数,也可以正确计算),然后对cap-1分别无符号右移1位、2位,4位、8位、16位(加起来正好是31位),并且每次移位后都与上一个数做按位或运算,通过这样的运算,会使得最终的结果低位都是1。那么最终对结果加1,就会得到一个2的幂次方数。
(3). 有参构造函数public HashMap(int initialCapacity)
该构造函数和上一个构造函数唯一不同之处就是不能指定负载因子。
源码如下:
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);//其实还是默认加载因子:0.75
}
2. HashMap插入机制
(1). 插入方法源码
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//初始化桶数组table,table被延迟插入新数据时再初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果桶中不包含键值对节点引用,说明当前数组下标下不存在任何数据,则将新键值对节点的引用存入桶中即可
if ((p = tab[i = (n - 1) & hash]) == null)//从底层数组取值
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//如果hash相等,并且equals方法返回true,这说明key相同,此时直接替换value即可,并且返回原值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))//又看到了熟悉的equals方法,这里我们hash值相等,key的值也相等,条件成立,把值赋值给e。(如果key的值不相等,就比较equals方法,也就是说,就算key是一个新new出来的对象,只要满足equals,也视为key相同)
e = p;//底层数组元素匹配成功,赋值给e
//如果第一个节点是树节点,则调用putTreeVal方法,将当前值放入红黑树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//如果第一个节点不是树节点,则说明还是链表节点,则开始遍历链表,将值存储到链表合适的位置
for (int binCount = 0; ; ++binCount) {
//如果遍历到了链接末尾,则创建链表节点,将数据存储到链表结尾
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);//把元素放到最后
//判断链表中节点树是否超多了阈值8,如果超过了则将链表转换为红黑树(当然不一定会转换,treeifyBin方法中还有判断)
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 如果长度超>=8,转换成红黑树
treeifyBin(tab, hash);//转换成红黑树
break;//如果底层数组元素第一个没匹配上,循环链表,直到匹配成功为止
}
//如果在链表中找到,完全相同的key,则直接替换value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;//如果hash值相等,key也相等或者equals相等,赋值给e
}
}
//e!=null说明只是遍历到中间就break了,该种情况就是在链表中找到了完全相等的key,该if块中就是对value的替换操作
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);//用新的value替换旧value并返回旧的value
return oldValue;
}
}
++modCount;
//加入value之后,更新size,如果超过阈值,则进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;
TreeNode<K,V> root = (parent != null) ? root() : this;
for (TreeNode<K,V> p = root;;) {//遍历树的节点
int dir, ph; K pk;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;//如果put的key==或equals节点的key,返回该节点
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
if (!searched) {
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q;
}
dir = tieBreakOrder(k, pk);
}
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next;
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
if (dir <= 0)//遍历完后还是没找到key,在树中添加新节点
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
(2) .插入流程图
① 在put一个k-v时,首先调用hash()方法来计算key的hashcode,而在hashmap中并不是简单的调用key的hashcode求出一个哈希码,还用到了扰动函数来降低哈希冲突。源码如下:
/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//高16位异或低16位
}
//从源码中可以看到,最终的哈希值是将原哈希码和原哈希码右移16位得到的值进行异或运算的结果。16正好是32的一半,因此hashmap是将hashcode的高位移动到了低位,再通过异或运算将高位散播的低位,从而降低哈希冲突。
至于为什么能够降低冲突呢,从作者对hash方法的注释中我们可以得出:
作者进行高位向低位散播的原因是:由于hashmap在计算bucket下标时,计算方法为hash&n-1,n是一个2的幂次方数,因此hash&n-1正好取出了hash的低位,比如n是16,那么hash&n-1取出的是hash的低四位,那么如果多个hash的低四位正好完全相等,这就导致了always collide(冲突),即使hash不同。因此将高位向低位散播,让高位也参与到计算中,从而降低冲突,让数据存储的更加散列。
②. 在计算出hash之后之后,调用putVal方法进行key-value的存储操作。
在putVal方法中首先需要判断table是否被初始化了(因为hashmap是延迟初始化的,并不会在创建对象的时候初始化table),如果table还没有初始化,则通过resize方法进行扩容。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
③. 通过**(n-1)&hash计算出当前key所在的bucket下标,如果当前table中当前下标中还没有存储数据,则创建一个链表节点直接将当前k-v存储在该下标的位置**。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
④. 如果table下标处已经存在数据,则首先判断当前key是否和下标处存储的key完全相等,如果相等则直接替换value,并将原有value返回,否则继续遍历链表或者存储到红黑树。
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
e = p;
⑤. 当前下标处的节点是树节点,则直接存储到红黑树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
⑥. 如果不是红黑树,则遍历链表,如果在遍历链表的过程中,找到相等的key,则替换value,如果没有相等的key,就将节点存储到链表尾部(jdk8中采用的是尾插法),并检查当前链表中的节点树是否超过了阈值8,如果超过了8,则通过调用treeifyBin方法将链表转化为红黑树。
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
⑦. 将数据存储完成之后,需要判断当前hashmap的大小是否超过扩容阈值Cap*load_fact(注意此处),如果大于阈值,则调用**resize()**方法进行扩容。
f (++size > threshold)
resize();
HashMap在扩容后的容量为原容量的2倍,起基本机制是创建一个2倍容量的table,然后将数据转存到新的散列表中,并返回新的散列表。和jdk1.7中不同的是,jdk1.8中多转存进行了优化,可以不再需要重新计算bucket下标,其实现resize()部分源码如下:
从源码中我们可以看出,如果一个key hash和原容量oldCap按位与运算结果为0,则扩容前的bucket下标和扩容后的bucket下标相等,否则扩容后的bucket下标是原下标加上oldCap。
使用的基本原理总结如下:
1、如果一个数m和一个2的幂次方数n进行按位与运算不等于0,则有:m&(n²-1)=m&(n-1)+n理解:一个2的幂次方数n,在二进制中只有一位为1(假设第k位是1),其他位均为0,那个如果一个数m和n进行按位与运算结果为0的话,则说明m的二进制第k位肯定为0,那么m的前n位和前n-1位所表示的值肯定是相等的。
2、如果一个数m和一个2的幂次方数n进行按位与运算等于0,则有:m&(n²-1)=m&(n-1)理解:一个2的幂次方数n,在二进制中只有一位为1(假设第k位是1),其他位均为0,那个如果一个数m和n进行按位与运算结果不为0的话,则说明m的二进制第k位肯定为1,那么m的前n位和前n-1位所表示的值的差恰好是第k位上的1所表示的数,二这个数正好是n。
3. 小结
在hashMap中放入(put)元素,有以下重要步骤:
1、计算key的hash值,算出元素在底层数组中的下标位置。
2、通过下标位置定位到底层数组里的元素(也有可能是链表也有可能是树)。
3、取到元素,判断放入元素的key是否或equals当前位置的key,成立则替换value值,返回旧值。
4、如果是树,循环树中的节点,判断放入元素的key是否或equals节点的key,成立则替换树里的value,并返回旧值,不成立就添加到树里。
5、否则就顺着元素的链表结构循环节点,判断放入元素的key是否==或equals节点的key,成立则替换链表里value,并返回旧值,找不到就添加到链表的最后。
精简一下,判断放入HashMap中的元素要不要替换当前节点的元素,key满足以下两个条件即可替换:
1、hash值相等。
2、==或equals的结果为true。
由于hash算法依赖于对象本身的hashCode方法,所以对于HashMap里的元素来说,hashCode方法与equals方法非常的重要。重写对象的equals方法一定要重写hashCode方法的原因,不重写的话,放到HashMap中可能会得不到你想要的结果!
4. HashMap知识点小结
HashMap:数组+链表+红黑树
负载因子:0.75
默认长度:16
扩容大小:2倍 扩容后元素重新分配 hash(key) 算法中的n-1尽可能都是1,才离散
链表元素存储:
1.7:头节点插入
1.8:尾节点插入
hash计算:
1.7:九次扰动
1.8:两次扰动
key.hashCode()的高16位 ^ 低16位
即:h = (h = key.hashCode()) ^ (h >>> 16)
下标确定:h & (n-1) 减少hash冲突
put过程:
树化:链表长度>=8 防止哈希表碰撞攻击;提高map效率
链化:链表长度<=6
HashTable: 线程安全,不建议使用
ConcurrentHashMap:线程安全,使用较多
1.7:分段锁机制实现
1.8:synchronized实现