1、HashMap 1.7与1.8区别
2、HashMap结构
JDK1.7采用的是数组+链表的形式,而JDK1.8采用的是数组+链表+红黑树在数组容量大于64且链表长度大于8的情况下会使用红黑树,当长度小于6后又将从红黑树转化为链表。
因为在链表的查询操作都是O(N)的时间复杂度,而且hashMap中查询操作也是占了很大比例的,如果当节点数量多,转换为红黑树结构,那么将会提高很大的效率,因为红黑树结构中,增删改查都是O(log n)。
HashMap一些关键常量:
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;//最小树化容量
1、默认加载因子0.75
- 如果负载因子为0.5甚至更低的话,最后得到的临时阈值明显会很小,这样会造成内存的浪费,也满足不了哈希表均匀分布的情况。
- 如果负载因子达到了1的情况,也就是Node数组存满了才发生扩容,这样会出现大量的哈希冲突的情况,出现链表过长,因此造成get查询数据的效率很低。
- 因此选择了0.5~1的折中数也就是0.75,均衡解决了上面出现的情况。
作为一个常规的规则,0.75是时间和空间之间的一种平衡。更高的值减少了空间开销,但增加了查找成本(反映在HashMap类的大多数操作中,包括get和put)。
0.75的数学依据:
假设一个bucket空和非空的概率为0.5,我们用s表示容量,n表示已添加元素个数。
用s表示添加的键的大小和n个键的数目。根据二项式定理,桶为空的概率为:
P(0) = C(n, 0) * (1/s)^0 * (1 - 1/s)^(n - 0)
因此,如果桶中元素个数小于以下数值,则桶可能是空的:
log(2)/log(s/(s - 1))
当s趋于无穷大时,如果增加的键的数量使P(0) = 0.5,那么n/s很快趋近于log(2):
log(2) ~ 0.693...
所以,合理值大概在0.7左右。
根据HashMap的扩容机制,他会保证capacity的值永远都是2的幂。
为了保证负载因子(loadFactor) * 容量(capacity)的结果是一个整数,这个值是0.75(3/4)比较合理,因为这个数和任何2的幂乘积结果都是整数。
3、HashMap是否线程安全
线程安全集合 | 线程不安全集合 |
Vector(比Arraylist多了个同步化机制)、Stack(栈,继承于Vector) | Arraylist |
Hashtable(比Hashmap多了个线程安全)、ConcurrentHashMap(一种高效但是线程安全的集合) | Hashmap |
- | LinkedList |
- | HashSet |
- | TreeSet |
- | TreeMap |
我们都知道HashMap是线程不安全的,线程不安全,现象表现为:
- 并发put碰撞导致数据丢失:多线程同时put时,如果计算出来的hashcode相同就会放在数组的同一位置,可能造成数据丢失。
- 并发put扩容导致数据丢失:多线程同时put发现同时需要扩容,扩容动作涉及到把数组拷贝到新数组中,扩容完成后只会有一个数组被保留下来,也就可能造成数据丢失。
- 并发put导致死循环CPU100%:多线程并发扩容时导致循环链表。
分析JDK7源码
扩容时新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的Hash表中
void resize(int newCapacity)
{
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
......
//创建一个新的Hash Table
Entry[] newTable = new Entry[newCapacity];
//将Old Hash Table上的数据迁移到New Hash Table上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
下面是迁移的代码:
void transfer(Entry[] newTable)
{
Entry[] src = table;
int newCapacity = newTable.length;
//下面这段代码的意思是:
// 从OldTable里摘一个元素出来,然后放到NewTable中
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
我们都知道HashMap是非线程安全的,上面这段代码在多线程的情况下会导致HashMap死循环,我们来演示下这个过程,最上面的是old hash 表,其中的Hash表的size=2, key = 3, 7, 5,现在要进行扩容了,假设我们有两个线程,线程二执行完成了,但是未退出while循环:
线程一现在开始执行,先是执行 newTalbe[i] = e,然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3),再看下一步
环形链接出现,线程一中e.next = newTable[i] 导致 key(3).next 指向了 key(7)
注意:此时线程二中的key(7).next 已经指向了key(3), 环形链表就这样出现了。
1.8解决死循环方案
- JDK8对HashMap死循环的解决方法是:扩容后,新数组中的链表顺序依然与旧数组中的链表顺序保持一致。
- JDK8虽然修复了死循环的bug,但是HashMap 还是非线程安全类,仍然会产生数据丢失等问题,线程安全类还是使用ConcurrentHashMap
4、HashMap扩容机制
扩容的时机
存在二种情况:
- 设定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;//将位置加上原数组的长度,就是新的位置坐标
}