JDK8和JDK7 HashMap的区别
什么是HashMap
Hashmap是一种快速的查找并且插入、删除性能都良好的一种 K/V键值对的数据结构,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。
区别
JDK7 | JDK8 | |
---|---|---|
底层结构 | 数组+链表(hash冲突元素使用链表存储,使用头插法) | 数组+链表(使用尾插法)+红黑树(当链表长度超过8时,采用红黑树存储) |
存储结构 | 使用Entry数组存储数据 | 使用Node数组存储数据 |
扩容机制 | 执行transfer()方法扩容,先扩容再插入新值。扩容后新的hash值,通过重新hash确定 | 执行reSize()方法扩容,先插入新值在扩容 。扩容后新的hash值,通过更加简单的方式处理,提升效率(通过原hash值 & 原容量 - 1) |
线程安全 | 线程不安全(多线程存储数据存在环状问题) | 线程不安全(多线程存储数据出现数据覆盖问题) |
hash算法 | 为了提高遍历效率,所以需要提高hash散列值,所以对hash算法进行复杂化 | 优化哈希算法,因为采用红黑树,大幅度提升了遍历速度,所以为了更好的效率,相对简化hash算法 |
重点疑问点
-
为什么在链表长度达到阈值(默认为8)之后,需要转变成红黑树。都是平衡二叉树,为什么使用红黑树,而不是用AVL(平衡二叉树)?
- 我们都知道,链表在插入的时候速度是最快的,时间复杂度为O(1),但是在读取的时候是O(n),因为需要通过头节点一个个往后遍历,所以当我们的链表长度越来越大的时候,hashMap读取的时间也会越来越大,导致查找的效率变低。而红黑树是一种平衡二叉树,可以自适应的进行长度平衡,使其时间复杂度降低为O(lg n),很大程度上提升了查找效率
- 至于为什么不使用AVL呢,因为AVL和红黑树虽然都是一种平衡二叉树,他们的时间复杂度都可以达到O(lg n),但是AVL是一种比红黑树更加严格的平衡,所以这也导致了AVL在进行数据插入之后需要进行更多次数的反转之后才能维持AVL的平衡,所以AVL更适合读取查找密集型任务,而红黑树更加适合hashMap这种插入修改密集型任务。
-
为什么在JDK7链表插入是使用头插法,而到JDK8使用尾插法呢?头插法不是比尾插法效率更高吗?
- 主要是为了修复并发状态下JDK7扩容时使用头插法产生的环状问题,环状问题产生会导致CPU飙升。
- 以下为JDK7使用头插法,导致环状问题产生的代码:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//采用头插法
//将e的next设置为newTable[i],也就是i对应的头节点
//然后将e设置为newTable[i],也就是i对应的头节点
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
- 我们通过进一步图解来讲解下,为什么并发状态下扩容时头插法会导致环状问题产生。
- 首先我们假设当前存在两个线程T1和T2,并且当前数组中存在数据,并且构成链表,线程都判断当前容器需要进行扩容,并且这时候T1优先获得线程执行权,开始执行。
- T1执行到Entry<K,V> next = e.next之后,也就是将指针指向各个数据,使用一个指针e指向小龙女,那么next就是指向杨过,之后T1线程释放线程执行权,交给T2线程管理
- T2线程拿到执行权之后,因为当前容器还未扩容完成,所以执行跟T1线程一样的操作,将指针指向各数据,进行扩容,使用头插法,将数据迁移到新的Entry数组中。那么就会得到下面的数据形式,头节点是杨过,杨过的next节点是小龙女。
- 在T2执行完扩容之后,T1又获得线程执行权,这时候就会继续往下之前未执行完的扩容程序。因为指针并不关心具体的值,只关心引用地址。所以指针指向还是没变,e是指向小龙女,e.next指向杨过。但是引用地址相对应的具体值,进行了变动,节点内部属性因为T2原因,导致next属性值进行了变动。所以之后程序的执行,相当于是在T2扩容完成之后的基础上进行的。如下:
之后执行,那么e指向的小龙女到达新数组的中,next所指向的杨过变成被e指向,如下:
//将e的next设置为newTable[i],也就是i对应的头节点
//然后将e设置为newTable[i],也就是i对应的头节点
e.next = newTable[i];
newTable[i] = e;
e = next;
继续执行后面代码,因为while(null != e) ,所以就会执行Entry<K,V> next = e.next;因为之前T2程序将杨过节点的next属性进行了变更,所以这里next又指向了小龙女,当我们继续往下执行,将杨过节点迁移到新数组中后,再次进行Entry<K,V> next = e.next就会导致环状问题产生:杨过的next属性是小龙女,小龙女的next属性是杨过
所以JDK8使用尾插法是为了解决环状问题,为什么尾插法可以解决环状问题,可以自行验证按照当前JDK7的代码运行步骤,看看是否尾插法会产生环状问题。