带着几个问题:
众所周知在jdk 1.8 hashmap的数据结构变成了链表+数组+红黑树,那么
- 相比于原本的链表和数组的结构,优点在哪?
- 哈希碰撞如何解决?
- 扩容时机和扩容原理?
- ConcurrentHashMap原理?
直接看代码吧
几个参数:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始容量
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认加载因子 也就是0.75F
static final int TREEIFY_THRESHOLD = 8; // 链表长度大于8时树化,数据8是由hashmap作者计算而出
static final int UNTREEIFY_THRESHOLD = 6; //还原成链表的阈值
static final int MIN_TREEIFY_CAPACITY = 64;//最小树化容量
由上面几个参数和英文注释,个人得出以下结论:
如果冲突的节点数已经达到8个,看是否需要改变冲突节点的存储结构,treeifyBin首先判断当前hashMap的长度,如果不足64,只进行resize,扩容table,同时最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树), 否则,若桶内元素太多时,则直接扩容,而不是树形化,为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD,这里就是64
一定是在大于MIN_TREEIFY_CAPACITY的时候,才会树化,无论链表的长度是否大于 TREEIFY_THRESHOLD
思考第一个问题,优点在哪?
相比于jdk1.7的数组+链表结构,先看看hshMap在jdk 1.8的结构,如下图,用的是数组+链表+红黑树的结构,也叫哈希桶,在jdk 1.8之前都是数组+链表的结构,因为在链表的查询操作都是O(N)的时间复杂度,而且hashMap中查询操作也是占了很大比例的,如果当节点数量多,转换为红黑树结构,那么将会提高很大的效率,因为红黑树结构中,增删改查都是O(log n)。
但阈值的存在毫无疑问是必要的,红黑树虽然效率高,但红黑树本身是一个复杂的数据结构,涉及到自旋的操作,在链表节点数量比较少的时候,很明显链表增删改查更为合适,所以并不能直接采用红黑树
再来看看hashmap存入数据的源码分析
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) //首次初始化的时候table为null
n = (tab = resize()).length; //对HashMap进行扩容
if ((p = tab[i = (n - 1) & hash]) == null) //根据hash值来确认存放的位置。如果当前位置是空直接添加到table中
tab[i] = newNode(hash, key, value, null);
else {
//如果存放的位置已经有值
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; //确认当前table中存放键值对的Key是否跟要传入的键值对key一致
else if (p instanceof TreeNode) //确认是否为红黑树
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//如果hashCode一样的两个不同Key就会以链表的形式保存
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 判断链表长度是否大于8
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; //替换新的value并返回旧的value
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();//如果当前HashMap的容量超过threshold则进行扩容
afterNodeInsertion(evict);
return null;
}
第二个问题 哈希碰撞
搬运一下 哈希碰撞浅谈
扩容时机和扩容原理
扩容的时机:
存在两种情况:
- 设定threshold, 当threshold = 默认容量(16) * 加载因子(0.75)的时候,进行resize()
- 如上文所说,treeifyBin首先判断当前hashMap的长度,如果不足64,只进行resize,扩容table,同时最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表
扩容原理
扩容一般是把长度扩为原来2倍,所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
这里有一个tableSizeFor方法,主要作用是会return一个大于给定整数的2的幂次方树,例如给定10,就会返回16,通过位运算可以验证
至于为什么一定要是2的幂次方呢?
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,因为hashmap在计算存放位置时,会发现最后一位一直是0,自然最后一位是0的位置就无法再放入元素,空间浪费会相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
这也是为什么默认值选为16,在小数据量的时候,16作为2的4次方,更能减少key之间的碰撞,提高查询的效率。
同时在Jdk 1.8中,在扩容HashMap的时候,不需要像1.7中去重新计算元素的hash,只需要看看原来的hash值新增的哪个二进制数是1还是0就好了,如果是0的话表示索引没有变,是1的话表示索引变成“oldCap+原索引”,这样即省去了重新计算hash值的时间,并且扩容后链表元素位置不会倒置。这也是扩容为2的幂次方的好处。配合源码理解:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;//获取当前数组大小oldCap
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)//数组和threshold扩大为原来的两倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;//说明调用了hashmap的有参构造函数,因为无参构造函数并没有对threshold进行初始化
else { // zero initial threshold signifies using defaults
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);
}
/*以上代码总结:
1.如果已经对底层数组初始化就进行扩容
2.如果数组长度已经是最大整数值了,最大值赋给threshold,不会在进行扩容
3.如果没有达到,数组长度扩展两倍,threshold扩招为原来的两倍
*/
threshold = newThr;//把上面计算来的newThr赋值给threshold
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab; //扩容后新数组给底层table
if (oldTab != null) {//若是扩容,执行以下方法,不是扩容则revise()结束
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;
}
注释感觉写的不便于阅读,这里对后半段代码关于三种情况存储 做一个解释:
- 当
e.next == null
的时候,也就是只有一个元素时最简单,直接通过(newCap-1)&hash找到需要放入的新下标,然后直接放入即可。 if(e instanceof TreeNode)
也就是e是红黑树结构时,这个方法并没有直接操作,而是调用了红黑树的split方法对此条件进行处理,源码从2133行开始是红黑树的相关操作,后面再进行红黑树的学习。- 当指定下标的一个链表的时候
Node<K,V> loHead = null, loTail = null;//lohead用户存储低位(位置不变)的key链表头,loTail用于存储链表尾
Node<K,V> hiHead = null, hiTail = null;//高位存储
Node<K,V> next;
do {
next = e.next;
//与原数组长度相与之后,得到结果为0,意味着在新数组中下标不变,组成新的链表
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
//结果非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;//将位置加上原数组的长度,就是新的位置坐标
}
ConcurrentHashMap
ConcurrentHashMap是位于JUC包下的类
在JDK1.7中,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
concurrentHashMap的默认大小同样为16,大多数参数和hashmap完全一致,可以理解为线程安全的
下图为查找到的jdk1.7的concurrenthashmap的数据结构
可以看到是由一个segment数组和16个hashEntry组成,每个hashEntry看作为一个table,单独加锁。
JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。
btw,此处diss一下网上多数文章,还是以jdk1.7的segment作为concurrenthashmap的数据结构,极易误导人(例如当初的我)
下图是JDK1.8的concurrenthashmap数据结构
Node是ConcurrentHashMap存储结构的基本单元,继承于HashMap中的Entry,用于存储数据
至于concurrenthashmap的原理分析,本人学习于ConcurrentHashMap原理分析,还在学习总结中,就不误人子弟了。