HashMap是Java程序员使用频率最高的用于映射(键、值对)处理的数据类型,它根据键的hashCode值存储数据,大多数状况下能够直接定位到它的值,于是具备很快的访问速度,但遍历顺序倒是不肯定的。 HashMap最多只容许一条记录的键为null,容许多条记录的值为null,且HashMap不是线程安全的类,可使用ConcurrentHashMap和Collections的SynchronizedMap方法使HashMap具备线程安全的能力。在JDK1.8中对HashMap底层的实现进行了优化,引入红黑树、扩容优化等。那就从新认识一下JDK1.8的HashMap,来看看对其作了什么优化。java
要搞清楚HashMap,首先须要知道HashMap是什么,即它的存储结构;其次弄明白它能干什么,即它的功能如何实现。咱们都知道HashMap使用哈希表来存储的数据的,根据key的哈希值进行存储的,可是不一样的key之间可能存在相同的哈希值,这样就会产生冲突;哈希表为解决冲突,能够采用开放地址法和链地址法等来解决问题,Java中的HashMap采用了链地址法来解决哈希冲突,简单来讲就是数组加链表的结合。在每一个数组元素上都一个链表结构,当存放数据的时候若是产生了哈希冲突,先获得数组下标,把数据放在对应下标元素的链表上。这里咱们思考一个问题,即便哈希算法设计的再合理,也免不了会出现拉链过长的状况,一旦出现拉链过长,则会严重影响HashMap的性能,在JDK1.8版本中,对数据结构作了进一步的优化,引入了红黑树;当链表长度太长(默认超过7)时,链表就转换为红黑树,以下图所示。node
HashMap是根据key的哈希值进行存取的,那个HashMap的性能和哈希算法的好坏有着直接的关系,哈希算法计算结果越分散均匀,哈希碰撞的几率就越小,map的存取效率就会越高。固然,也和哈希数组的大小有关系,若是哈希数组很大,即便较差的哈希算法也会比较分散,若是哈希数组较小,即便较好的哈希算法也会出现较多的碰撞,因此就须要权衡空间和时间成本,找到比较平衡的值。程序员
JDK1.8版本也是权衡了时间、空间成本以及效率,对以前的版本作出了不少优化;不只对数据结构进行了优化,除此以外还对扩容进行的优化,大大的提升的HashMap的性能。下面咱们经过源码来一块儿看一下具体的实现。算法
咱们来看一下HashMap中比较重要的几个属性。数组
//默认的初始容量,必须是2的幂次方.
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//所能容纳 key-value 个数的极限,当Map 的size > threshold 会进行扩容 。容量 * 扩容因子
int threshold;
//hashMap最大的容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//HashMap 默认的桶数组的大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
//默认的加载因子.当容量超过 0.75*table.length 扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//HashMap的加载因子,在构造器中指定的.
final float loadFactor;
//链表节点数大于8个链表转红黑树
static final int TREEIFY_THRESHOLD = 8;
//红黑树节点转换链表节点的阈值, 6个节点转
static final int UNTREEIFY_THRESHOLD = 6;
//以Node数组存储元素,长度为2的次幂。
transient Node<K,V>[] table;
// 转红黑树, table的最小长度
static final int MIN_TREEIFY_CAPACITY = 64;
// 链表节点, 继承自Entry
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
// ... ...
}
// 红黑树节点
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;
// ...
}
复制代码
HashMap中的属性仍是比较好理解的。其实到这里会有一个疑问,为何默认的哈希桶数组table的长度为16,并且长度必须为2的n次方呢?安全
这里咱们先说一下为何哈希数组的长度是2的n次方。bash
其实不论是在JDK1.7仍是JDK1.8中,计算key索引位置都是经过hash & (length-1)计算得来的。数据结构
咱们应该知道 hash % length 等价于 hash & (length - 1)。并发
假若有一个key的哈希值二进制以下:这里咱们就只看低位。
hahsCode 0010 0011 ———————转成十进制—————————> 35
& %
(length-1)=15: 0000 1111 length = 16
-----------------------------------------------------------------------------------------------
(二进制) 0011 = (十进制)3 3
复制代码
为何不用 hash % length 计算索引位,要使用 hash & (length -1)来计算呢?计算机底层是二进制数进行计算和存储,&是接近计算机底层运算,相比于% 运算效率上应该会快。app
那为何length必须是2的n次方呢?
hahsCode 0010 0011 0010 1111
&
(length-1)=15: 0000 1111 (length-1) = 13: 0000 1111
----------------------------------------------------------------------------------------------
0011 1111
复制代码
hahsCode 0010 1110 1110 1100
&
(length-1)=13: 0000 0101 (length-1) = 13: 0000 0101
----------------------------------------------------------------------------------------------
0100 0100
复制代码
其实咱们能够发现,当哈希数组的长度为2的n次方时,length - 1的二进制码全都是1,这样的话索引的位置,彻底依赖于hash值的低位值,并且产生冲突的概率要比容量不是2的n次方的几率要低,索引位就彻底依赖于哈希值的低位,因此只要哈希值分布均匀,那产生冲突的几率就会低不少,故而length是2的n次方更优。
其次,当length为2的n次方时,也方便作扩容,JDK1.8在扩容算法上也进行了优化,使用的方法也很是巧妙。会在扩容方法的时候讲到。
不论是增长、删除、查找,都须要定位到哈希桶的数组位置,前面也说过HashMap的数据结构是数组和链表的结合,因此咱们固然但愿这个HashMap里面的元素位置尽可能分布均匀些,尽可能使得每一个位置上的元素数量只有一个,那么当咱们用hash算法求得这个位置的时候,立刻就能够知道对应位置的元素就是咱们要的,不用再遍历链表查询,大大优化了查询效率。
tableSizeFor()这个方法,就是保证在HashMap()进行初始化的时候,哈希桶数组的大小永远是2^n。
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;
/** 假如如今传入的参数cap = 3 那 n = 2 对应的二进制 为 10 n = n | n>>>1 10|01 获得 11 .... .... n = 11(二进制) = (10进制) 3 最后return 返回的是4 */
}
复制代码
//JDK1.8的Hash算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// JDK 1.7的Hash算法
static final int hash(int h) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
//索引位置
index = hash & (length-1);
//JDK1.7 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
//JDK 1.8 简化了hash函数 = 只作了2次扰动 = 1次位运算 + 1次异或运算。
复制代码
在JDK1.8的实现中,优化了高位运算的算法,经过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,相比于JDK1.7来讲,JDK1.8下降了哈希函数扰动的次数,也算是优化了hash算法。这么作能够在HashMap容量较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
假若有一个key的哈希值二进制以下
hahsCode 0000 0000 0011 0011 0111 1010 1000 1011
hahsCode>>>16 0000 0000 0000 0000 0000 0000 0011 0011
———————————————————————————————————————————————————————————————
位或^运算 0000 0000 0011 0011 0111 1010 1011 1000
&
HashMap.size()-1 0000 0000 0000 0000 0000 0000 0000 1111
———————————————————————————————————————————————————————————————
0000 0000 0000 0000 0000 0000 0000 1000 转成十进制是 8
复制代码
从网上找到了一个流程图,感受很不错,就直接拿过来用了,嘿嘿....画的也是比较清楚的。看着流程图,再结合源码一看就明白。
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)
//若是哈希桶数组为空,对其进行初始化。默认的桶数组大小为16
n = (tab = resize()).length;
//若是桶数组不为空,获得计算key的索引位置,判断此索引所在位置是否已经被占用了。
if ((p = tab[i = (n - 1) & hash]) == null)
//若是没有被占用,那就封装成Node节点,放入哈希桶数组中。
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//若是要插入的Node节点已经存在,那就将旧的Node替换。
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);
else {
//若是是普通节点,循环哈希桶对应的链表,将节点插入到链表末尾
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {d
p.next = newNode(hash, key, value, null);
//若是链表的长度大于7,就把节点转成树节点
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;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//若是超过了扩容的临界点,就进行扩容。
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
复制代码
get方法相对于put方法可能简单一点,经过源码一看就能明白。废话很少说,直接上代码看一下吧。
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;
//哈希桶数组不为空,且根据传入的key计算出索引位置的Node不为空。
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//若是计算出来的第一个哈希桶位置的Node就是要找的Node节点,直接返回。
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;
}
复制代码
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//若是老的HashMap容量不为空
if (oldCap > 0) {
//若是容量大于或者等于这个扩容的临界点
if (oldCap >= MAXIMUM_CAPACITY) {
//修改阈值为2^31-1
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//若是老的容量为0, 老的阈值大于0, 是由于初始容量没有被放入阈值,则将新表的容量设置为老表的阈值
else if (oldThr > 0)
newCap = oldThr;
else {
//老表的容量为0, 老表的阈值为0,这种状况是没有传初始容量,将阈值和容量设置为默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 将当前阈值设置为刚计算出来的新的阈值,定义新表,容量为刚计算出来的新容量。将旧Hash桶中的元素,移动到新的Hash数组中。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 若是原来的容量不为空,把每一个bucket都移动到新的buckets中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 将老表的节点设置为空, 以便垃圾收集器回收空间
oldTab[j] = null;
//哈希桶位置只有一个节点。rehash以后再放到newTab里面去
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//若是是红黑树节点,则进行红黑树的重hash分布
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//若是是普通的链表节点,则进行普通的重hash分布
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//若是要移动节点的hash值与老的容量进行与运算为0,则扩容后的索引位置跟老表的索引位置同样
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//若是e的hash值与老表的容量进行与运算不为0,则扩容后的索引位置为:老表的索引位置+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;
}
复制代码
在源码中有这么一段(e.hash & oldCap) == 0,怎么理解这个呢,咱们经过下面的来看一下
假设扩容以前 数组大小为16
假若有两个key:
key1(hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1011 1000
key2(hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1010 1000
&
length-1 = 15 0000 0000 0000 0000 0000 0000 0000 1111
——————————————————————————————————————————————————————————————————
key1: 1000 转成十进制 8
key2: 1000 转成十进制 8
哈希冲突的两个key,在扩容到32以后
key1(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1011 1000
key2(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1010 1000
&
length-1 = 31 0000 0000 0000 0000 0000 0000 0001 1111
——————————————————————————————————————————————————————————————————
key1: 1 1000 转乘二进制 24=16+8
key2: 0 1000 转乘二进制 8
复制代码
经过上面咱们也能看到,原来在同一个位置上的两个key,经过扩容以后的位置要不在原来的位置上,要不在oldCap+原位置上。这样不须要像JDK1.7的实现那样从新计算hash,只须要看看原来的hash值新增的那个bit是1仍是0就行了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。同时也是更加充分的说明了,为何HashMap的容量必须是2的n次方了。
JDK1.8的这个设计确实很是的巧妙,既省去了从新计算hash值的时间,并且同时,因为新增的1bit是0仍是1能够认为是随机的,所以resize的过程,均匀的把以前的冲突的节点分散到新的bucket了。