Java集合中的HashMap类
jdk1.8.0_144
HashMap作为最常用集合之一,继承自AbstractMap。JDK8的HashMap实现与JDK7不同,新增了红黑树作为底层数据结构,结构变得复杂,效率变得更高。为满足自身需要,也重新实现了很多AbstractMap中的方法。本文会围绕HashMap,详细探讨HashMap的底层数据结构、扩容机制、并发环境下的死循环问题等。
JDK8同JDK7一样对Map.Entry进行了重新实现,改了个名字叫——Node,我想这是因为在红黑树中更方便理解,方法和JDK7大体相同只是取消了几个方法。并且此时的Node节点(也就是Entry)结构更加完善:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //节点hash值
final K key; //key值
V value; //value值
Node<K,V> next; //指向的下一个节点 //省略,由于JDK8的Map接口新增了几个compare比较的方法,Node直接就继承了 }
Node作为HashMap维护key-value的内部数据结构比较简单,下面是HashMap重新实现Map的方法。
public int size()
HashMap并没有继承AbstractMap的size方法,而是重写了此方法。HashMap在类中定义了一个size变量,再此处直接返回size变量而不用调用entrySet方法返回集合再计算。可以猜测这个size变量是当插入一个key-value键值对的时候自增。
public boolean isEmpty()
判断size变量是否0即可。
public boolean containsKey(Object key)
AbstractMap通过遍历Entry节点的方式实现了这个方法,显然HashMap觉得效率太低并没有复用而是重写了这个方法。
JDK8的HashMap底层数据结构引入了红黑树,它的实现要比JDK7略微复杂,我们先来看JDK7关于这个方法的实现。
//JDK7,HashMap#containsKey
public boolean containsKey(Object key) {
return getEntry(key) != null; //调用getEntry方法
}
getEntry实现的思路也比较简单,由于JDK7的HashMap是数组+链表的数据结构,当key的hash值冲突的时候使用链地址法直接加到冲突地址Entry的next指针行程链表即可。所以getEntry方法的思路也是先计算key的hash值,计算后再找到它在散列表的下标,找到过再遍历这个位置的链表返回结果即可。
JDK8加入了红黑树,在链表的个数达到阈值8时会将链表转换为红黑树,如果此时是红黑树,则不能通过遍历链表的方式寻找key值,所以JDK8对该方法进行了改进主要是需要遍历红黑树,有关红黑树的具体算法在此不多介绍。
//JDK8,HashMap#containsKey
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null; //JDK8中新增了一个getNode方法,且将key的hash值计算好后作为参数传递。
}
//HashMap#getNode
final Node<K,V> getNode(int hash, Object key) {
//此方法相比较于JDK7中的getEntry基本相同,唯一不同的是发现key值冲突过后会通过“first instanceof TreeNode”检查此时是否是红黑树结构。如果是红黑树则会调用getTreeNode方法在红黑树上进行查询。如果不是红黑树则是链表结构,遍历链表即可。
}
public boolean containsValue(Object value)
遍历散列表中的元素
public V get(Object key)
在JDK8中get方法调用了containsKey的方法getNode,这点和JDk7的get方法中调用getEntry方法类似。
- 将参数key的hash值和key作为参数,调用getNode方法;
- 根据(n - 1) & hash(key)计算key值所在散列桶的下标;
- 取出散列桶中的key与参数key进行比较:
3.1 如果相等则直接返回Node节点;
3.2 如果不相等则判断当前节点是否有后继节点:
3.2.1 判断是否是红黑树结构,是则调用getTreeNode查询键值为key的Node 节点;
3.2.2 如果是链表结构,则遍历整个链表。
public V put(K key, V value)
这个方法最为关键,插入key-value到Map中,在这个方法中需要计算key的hash值,然后通过hash值计算所在散列桶的位置,判断散列桶的位置是否有冲突,冲突过后需要使用链地址法解决冲突,使之形成一个链表,从JDK8开始如果链表的元素达到8个过后还会转换为红黑树。在插入时还需要判断是否需要扩容,扩容机制的设计,以及在并发环境下扩容所带来的死循环问题。
由于JDK7比较简单,我们先来查看JDK7中的put方法源码。
JDK7——HashMap#put
//JDK7, HashMap#put
public V put(K key, V value) {
//1. 首先判断是否是第一次插入,即散列表是否指向空的数组,如果是,则调用inflateTable方法对HashMap进行初始化。
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//2. 判断key是否等于null,等于空则调用putForNullKey方法存入key为null的key-value,HashMap支持key=null。
if (key == null)
return putForNullKey(value);
//3. 调用hash方法计算key的hash值,调用indexFor根据hash值和散列表的长度计算key值所在散列表的下标i。
int hash = hash(key);
int i = indexFor(hash, table.length);
//4. 这一步通过循环遍历的方式判断插入的key-value是否已经在HashMap中存在,判断条件则是key的hash值相等,且value要么引用相等要么equals相等,如果满足则直接返回value。
for (Entry<K,V> e = table[i]; e != null; e = e.next) {//如果插入位置没有散列冲突,即这个位置没有Entry元素,则不进入循环。有散列冲突则需要遍历链表进行判断。
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//插入
modCount++;//记录修改次数,在并发环境下通过迭代器遍历时会抛出ConcurrentModificationException异常(Fail-Fast机制),就是通过这个变量来实现的。在迭代器初始化过程会将modCount赋给迭代器的ExpectedModCount,是否会抛出ConcurrentModificationException异常的实现就是在迭代过程中判断modCount是否与ExpectedModCount相等。
//插入key-value键值对,传入key的hash值、key、value、散列表的插入位置i
addEntry(hash, key, value, i);
}
//JDK7,HashMap#addEntry,这个方法是put方法的实现核心,在其中会判断是否冲突,是否扩容。
void addEntry(int hash, K key, V value, int bucketIndex) {
//第一步判断就是是否扩容,需要扩容的条件需要满足以下两个:1、Map中的key-value的个数大于等于Map的容量threshold(threshold=散列表容量(数组大小)*负载因子)。2、key值所对应的散列表位置不为null。
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length); //关键的扩容机制,扩容后的大小是之前的两倍
hash = (null != key) ? hash(key) : 0; //计算key的hash值
bucketIndex = indexFor(hash, table.length); //重新计算key所在散列表的下标
}
//创建Entry节点并插入,每次插入都会插在链表的第一个位置。
createEntry(hash, key, value, bucketIndex);
}
来看看HashMap是如何扩容的。JDK7HashMap扩容的大小是前一次散列表大小的两倍2 * table.length
void resize(int newCapacity)
在这个方法中最核心的是transfer(Entry[], boolean)方法,第一个参数表示扩容后新的散列表引用,第二参数表示是否初始化hash种子。
结合源码我们用图例来说明HashMap在JDK7中是如何进行扩容的。
假设现在有如下HashMap,初始容量initialCapacity=4,负载因子loadFactor=0.5。初始化时阈值threshold=4*0.5=2。也就是说在插入第三个元素时,HashMap中的size=3大于阈值threshold=2,此时就会进行扩容。我们从来两种情况来对扩容机制进行分析,一种是两个key-value未产生散列冲突,第二种是两个key-value产生了散列冲突。
1. 扩容时,当前HashMap的key-value未产生散列冲突
此时当插入第三个key-value时,HashMap会进行扩容,容量大小为之前的两倍,并且在扩容时会对之前的元素进行转移,未产生冲突的HashMap转移较为简单,直接遍历散列表对key重新计算出新散列表的数组下标即可。
2. 扩容时,当前HashMap的key-value产生散列冲突
在对散列冲突了的元素进行扩容转移时,需要遍历当前位置的链表,链表的转移若新散列表还是冲突则采用头插法的方式进行插入,此处需要了解链表的头插法。同样通过for (Entry<K,V> e : table)遍历散列表中的元素,判断当前元素e是否为null。由例可知,当遍历到第2个位置的时候元素e不为null。此时创建临时变量next=e.next。
重新根据新的散列表计算e的新位置i,后面则开始通过头插法把元素插入进入新的散列表。
通过头插法将A插入进了新散列表的i位置,此时指针通过e=next继续移动,待插入元素变成了B,如下所示。
此时会对B元素的key值进行hash运算,计算出它在新散列表中的位置,无论在哪个位置,均是头插法,假设还是在位置A上产生了冲突,头插法后则变成了如下所示。
可知,在扩容过程中,链表的转移是关键,链表的转移通过头插法进行插入,所以正是因为头插法的原因,新散列表冲突的元素位置和旧散列表冲突的元素位置相反。
关于HashMap的扩容机制还有一个需要注意的地方,在并发条件下,HashMap不仅仅是会造成数据错误,致命的是可能会造成CPU100%被占用,原因就是并发条件下,由于HashMap的扩容机制可能会导致死循环。下面将结合图例说明,为什么HashMap在并发环境下会造成死循环。
假设在并发环境下,有两个线程现在都在对同一个HashMap进行扩容。
此时线程T1对扩容前的HashMap元素已经完成了转移,但由于Java内存模型的缘故线程T2此时看到的还是它自己线程中HashMap之前的变量副本。此时T2对数据进行转移,如下图所示。
进一步地,在T2中的新散列表中newTable[i]指向了元素A,此时待插入节点变成了B,如下图所示。
原本在正常情况下,next会指向null,但由于T1已经对A->B链表进行了转置B->A,即next又指回了A,并且B会插入到T2的newTable[i]中。
由于此时next不为空,下一步又会将next赋值给e,即e = next,反反复复A、B造成闭环形成死循环。
所以,千万不要使用在并发环境下使用HashMap,一旦出现死循环CPU100%,这个问题不容易复现及排查。并发环境一定需要使用ConcurrentHashMap线程安全类。
探讨了JDK7中的put方法,接下来看看JDK8新增了红黑树HashMap是如何进行put,如何进行扩容,以及如何将链表转换为红黑树的。
JDK8——HashMap#put
//JDK8, HashMap#put
public V put(K key, V value) {
//在JDK8中,put方法直接调用了putVal方法,该方法有5个参数:key哈希值,key,value,onlyIfAbsent(如果为ture则Map中已经存在该值的时候将不会把value值替换),evict在HashMap中无意义
return putVal(hash(key), key, value, false, true);
}
所以关键的方法还是putVal。
//JDK8中putVal方法和JDK7中put方法中的插入步骤大致相同,同样需要判断是否是第一次插入,插入的位置是否产生冲突,不同的是会判断插入的节点是“链表节点”还是“红黑色”节点。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
//1. 是否是第一次插入,是第一次插入则复用resize算法,对散列表进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//2. 通过i = (n - 1) & hash计算key值所在散列表的下标,判断tab[i]是否已经有元素存在,即有无冲突,没有则直接插入即可,注意如果插入的key=null,此处和JDK7的策略略有不同,JDK7是遍历散列表只要为null就直接插入,而JDK8则是始终会插入第一个位置,即使有元素也会形成链表
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//3. tab[i]已经有了元素即产生了冲突,如果是JDK7则直接使用头插法即可,但在JDK8中HashMap增加了红黑树数据结构,此时有可能已经是红黑树结构,或者处在链表转红黑树的临界点,所以此时需要有几个判断条件
else {
//3.1 这是一个特殊判断,如果tab[i]的元素hash和key都和带插入的元素相等,则直接覆盖value值即可
12 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
13 e = p;
//3.2 待插入节点是一个红黑树节点
else if (p instanceof TreeNode)
16 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
17 //3.3 插入后可能继续是一个链表,也有可能转换为红黑树。在元素个数超过8个时则会将链表转换为红黑树,所以第一个则需要一个计数器来遍历计算此时tab[i]上的元素个数
18 else {
19 for (int binCount = 0; ; ++binCount) {
20 if ((e = p.next) == null) {
21 p.next = newNode(hash, key, value, null); //遍历到当前元素的next指向null,则通过尾插法插入,这也是和JDK7采用头插法略微不同的地方
22 if (binCount >= TREEIFY_THRESHOLD - 1) // tab[i]的数量超过了临界值8,此时将会进行链表转红黑树的操作,并跳出循环
23 treeifyBin(tab, hash);
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) //这种情况同3.1,出现了和插入key相同的元素,直接跳出循环,覆盖value值即可,无需插入操作
break;
p = e;
29 }
30 }
31 if (e != null) { //这种情况表示带插入元素的key在Map中已经存在,此时没有插入操作,直接覆盖value值即可
32 V oldValue = e.value;
33 if (!onlyIfAbsent || oldValue == null)
34 e.value = value;
35 afterNodeAccess(e);
36 return oldValue;
37 }
38 }
39 ++modCount; //修改计数,在使用Iterator迭代器时会和这个变量比较,如果不相等,则会抛出ConcurrentModificationException异常
40 if (++size > threshold) //判断是否需要扩容
41 resize();
42 afterNodeInsertion(evict); //并无意义
43 return null;
44 }
从上面的JDK7和JDK8的put插入方法源码分析来看,JDK8确实复杂了不少,在没有耐心的情况下,这个“干货”确实显得比较干,我试着用下列图解的方式回顾JDK7和JDK8的插入过程,在对比过后接着对JDK8中的红黑树插入、链表转红黑树以及扩容作分析。
综上JDK7和JDK8的put插入方法大体上相同,其核心均是计算key的hash并通过hash计算散列表的下标,再判断是否产生冲突。只是在实现细节上略有区别,例如JDK7会对key=null做特殊处理,而JDK8则始终会放置在第0个位置;而JDK7在产生冲突时会使用头插法进行插入,而JDK8在链表结构时会采用尾插法进行插入;当然最大的不同还是JDK8对节点的判断分为了:链表节点、红黑树节点、链表转换红黑树临界节点。
对于红黑树的插入暂时不做分析,接下来是对JDK8扩容方法的分析。
// JDK8,HashMap#resize扩容,HashMap扩容的大小仍然是前一次散列表大小的两倍
final Node<K,V>[] resize() {
3 //1. 由于JDK8初始化散列表时复用了resize方法,所以前面是对oldTab的判断,是否为0(表示是初始化),是否已经大于等于了最大容量。判断结束后newTab会扩大为oldTab的两倍,同样newThr(阈值)也是以前的两倍。源码略。
4 //2. 确定好newTab的大小后接下来就是初始化newTab散列表数组
5 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
6 table = newTab;
7 //3. 如果是初始化(即oldTab==null),则直接返回新的散列表数组,不是则进行转移
8 //4. 首先还是遍历散列表
9 for (int j = 0; j < oldCap; ++j) {
10 //5. e = oldCap[i] != null,则继续判断
11 //5.1 当前位置i,是否有冲突,没有则直接转移
12 if (e.next == null)
13 newTab[e.hash & (newCap - 1)] = e; //这里并没有对要转移的元素重新计算hash,对于JDK7来会通过hash(e.getKey()) ^ newCap重新计算e在newTab中的位置,此处则是e.hash & (newCap - 1),减少了重新计算hash的过程。扩容后的位置要么在原来的位置上,要么在原索引 + oldCap位置
14 //5.2 判断是否是红黑树节点
15 else if (e instanceof TreeNode)
16 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
17 //5.3 判断是否是链表节点
18 else {
…
20 }
}
}
JDK8的扩容机制相比较于JDK7除了增加对节点是否为红黑树的判断,其余大致相同,只是做了一些微小的优化。特别在于在JDK8中并不会重新计算key的hash值。
public V remove(Object key)
如果已经非常清楚put过程,我相信对于HashMap中的其他方法也基本能知道套路。remove删除也不例外,计算hash(key)以及所在散列表的位置i,判断i是否有元素,元素是否是红黑树还是链表。
这个方法容易陷入的陷阱是key值是一个自定义的pojo类,且并没有重写equals和hashCode方法,此时用pojo作为key值进行删除,很有可能出现“删不掉”的情况。这需要重写equals和hashCode才能使得两个pojo对象“相等”。
剩下的方法思路大同小异,基本均是计算hash、计算散列表下标i、遍历、判断节点类型等等。本文在弄清put和resize方法后,一切方法基本上都能举一反三。所以在看完本文后,你应该试着问自己以下几个问题:
- HashMap的底层数据结构是什么?
- HashMap的put过程?
- HashMap的扩容机制?
- 并发环境下HashMap会带来什么致命问题?
这是一个能给程序员加buff的公众号
Java集合中的HashMap类的更多相关文章
- Java集合中的LinkedHashMap类
jdk1.8.0_144 本文阅读最好先了解HashMap底层,可前往<Java集合中的HashMap类>. LinkedHashMap由于它的插入有序特性,也是一种比较常用的Map集合. ...
- java集合中的HashMap源码分析
1.hashMap中的成员分析 transient Node<K,V>[] table; //为hash桶的数量 /** * The number of key-value mapping ...
- Java集合框架(常用类) JCF
Java集合框架(常用类) JCF 为了实现某一目的或功能而预先设计好一系列封装好的具有继承关系或实现关系类的接口: 集合的由来: 特点:元素类型可以不同,集合长度可变,空间不固定: 管理集合类和接口 ...
- Java集合框架:HashMap
转载: Java集合框架:HashMap Java集合框架概述 Java集合框架无论是在工作.学习.面试中都会经常涉及到,相信各位也并不陌生,其强大也不用多说,博主最近翻阅java集合框架的源码以 ...
- Java 集合系列 09 HashMap详细介绍(源码解析)和使用示例
java 集合系列目录: Java 集合系列 01 总体框架 Java 集合系列 02 Collection架构 Java 集合系列 03 ArrayList详细介绍(源码解析)和使用示例 Java ...
- Java 集合系列 11 hashmap 和 hashtable 的区别
java 集合系列目录: Java 集合系列 01 总体框架 Java 集合系列 02 Collection架构 Java 集合系列 03 ArrayList详细介绍(源码解析)和使用示例 Java ...
- Java集合中List,Set以及Map等集合体系详解
转载请注明出处:Java集合中List,Set以及Map等集合体系详解(史上最全) 概述: List , Set, Map都是接口,前两个继承至collection接口,Map为独立接口 Set下有H ...
- Java集合框架之HashMap浅析
Java集合框架之HashMap浅析 一.HashMap综述: 1.1.HashMap概述 位于java.util包下的HashMap是Java集合框架的重要成员,它在jdk1.8中定义如下: pub ...
- Java集合中List、Set以及Map
概述: List , Set, Map都是接口:List , Set继承至Collection接口,Map为独立接口 Set下有HashSet,LinkedHashSet,TreeSet List下有 ...
随机推荐
- Vi/Vim的快捷方式
1 vi/ vim键盘图 2 文字解说 进入编辑模式的6种方式: i在光标前插入 I在行首插入 a在光标后插入 A在行末插入 o在下一行插入 O在上一行插入 删除字符 x 删除当前字符 X 删除前一个 ...
- Golang学习 - strconv 包--数据类型转换
// 将布尔值转换为字符串 true 或 false func FormatBool(b bool) string // 将字符串转换为布尔值 // 它接受真值:1, t, T, TRUE, true ...
- sql server两个时间段内,求出周末的量
公司有个表记录了出差(加班)的初始时间和截止时间,现在要计算出加班时间,之前的设计并没有考虑到这部分,因此本人通过sql重新计算周末数 表formmain starttime endtime 使用游标 ...
- CUP、内存、磁盘是如何在一起工作的
IT技术发展到今天,计算机能做的事情可谓复杂的多.那么计算机是如何做出如此复杂的运算的呢? 不准确的说,计算机主要做两件事,数据计算和数据存储. 第一先说说计算机是如何计算的吧. 我们平时见到的所有计 ...
- 用node.js搭建本地服务器
我的第一篇笔记来写写node.js,我对node.js的并不是很了解,基本的项目路径变换还是会的.原先我下载node.js就是我想学vue.js,后来因为工作的繁忙搁浅了我的计划.最近在学习phase ...
- ubuntu上lamp环境搭建
首先,介绍个彻底删除linux已经安装的软件的方法. sudo apt-get purge mysql-server mysql-client mysql-common mysql-server-5. ...
- HDU - 4496 City 逆向并查集
思路:逆向并查集,逆向加入每一条边即可.在获取联通块数量的时候,直接判断新加入的边是否合并了两个集合,如果合并了说明联通块会减少一个,否则不变. AC代码 #include <cstdio> ...
- iOS开发之emoji处理
看似emoji处理应该和我们iOS开发有着很大的关系,实则不然,对于emoji的处理只要在服务器端做就可以了,我所说的处理是我们将表情提交到服务器,那么在服务器端需要做些什么处理呢? 服务器如果直接存 ...
- shell脚本 awk工具
awk工具概述awk编程语言/数据处理引擎基于模式匹配检查输入文本,逐行处理并输出通常在shell脚本中,或取指定的数据单独用时,可对文本数据做统计 命令格式格式一:awk [选项] '[条件]{编辑 ...
- 6.2 PowerPC处理器如何处理MSI中断请求
PowerPC处理器使用OpenPIC中断控制器或者MPIC中断控制器,处理外部中断请求.其中MPIC中断控制器基于OpenPIC中断控制器,但是作出了许多增强,目前Freescale新推出的Powe ...