Java8 HashMap详解(转)
Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。
根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。
为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
来一张图简单示意一下吧:
先回答几个问题:
1.HashMap的什么时候扩容,哪些操作会触发
当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值,即当前数组的长度乘以加载因子的值的时候,就要自动扩容。默认容量为16,扩容因子是0.75,阈值为12。
有参构造方法和put、merge操作都会导致扩容。
2.HashMap push方法的执行过程?
最先判断桶的长度是否为0,为0的话则需要先对桶进行初始化操作,接着,求出hashcode并通过扰动函数确定要put到哪个桶中,若桶中没有元素直接插入,若有元素则判断key是否相等,如果相等的话,那么就将value改为我们put的value值,若不等的话,那么此时判断该点是否为树节点,如果是的话,调用putreeval方法,以树节点的方式插入,如果不是树节点,那么就遍历链表,如果找到了key那么修改value,没找到新建节点插到链表尾部,最后判断链表长度是否大于8 是否要进行树化。
3.HashMap检测到hash冲突后,将元素插入在链表的末尾还是开头?
因为JDK1.7是用单链表进行的纵向延伸,采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。
4.JDK1.8还采用了红黑树,讲讲红黑树的特性,为什么人家一定要用红黑树而不是AVL、B树之类的?
在CurrentHashMap中是加锁了的,实际上是读写锁,如果写冲突就会等待,如果插入时间过长必然等待时间更长,而红黑树相对AVL树B树的插入更快,AVL树查询确实更快一些,但是对于操作密集型,红黑树的旋转更少,效率更高。
5.HashMap get方法的执行过程?
首先和put一样,确定对应的key在哪一个桶中,如果桶容量为0或者该桶内没有元素直接返回空,反之会判断该桶会检查桶中第一个元素是否和要查的key相等,相等的话直接返回,不相等的话判断该节点是否为树节点,是的话以树节点方式遍历整棵树来查找,不是的话那就说明存储结构是链表,以遍历链表的方式查找。
源码与其中的算法技巧
1.构造方法
public HashMap(int initialCapacity, float loadFactor) { //当指定的 initialCapacity (初始容量) < 0 的时候会抛出 IllegalArgumentException 异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity); //当指定的 initialCapacity (初始容量)= MAXIMUM_CAPACITY(最大容量) 的时候
if (initialCapacity > MAXIMUM_CAPACITY)
//初始容量就等于 MAXIMUM_CAPACITY (最大容量)
initialCapacity = MAXIMUM_CAPACITY; //当 loadFactory(负载因子)< 0 ,或者不是数字的时候会抛出 IllegalArgumentException 异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor; //tableSizeFor()的主要功能是返回一个比给定整数大且最接近2的幂次方整数
//比如我们给定的数是12,那么tableSizeFor()会返回2的4次方,也就是16,因为16是最接近12并且大于12的数
this.threshold = tableSizeFor(
initialCapacity);
}
执行顺序注释写的很清楚了,但是有些同学对最后对 tableSizeFor 方法很有疑问,这是用来求传入容量的最小2的幂次方整数的。
static final int tableSizeFor(int cap) {
int n = cap - ;
n |= n >>> ;
n |= n >>> ;
n |= n >>> ;
n |= n >>> ;
n |= n >>> ;
return (n < ) ? : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + ;
}
这是一系列的或操作,举个例子
n-=;// n=1000000(二进制)
...//16、8无变化
n|=n>>>;//n=n|(n>>>4)=1000000|0000100=1000100
n|=n>>>;//n=n|(n>>>2)=1000100|0010001=1010101
...
看出规律来了吧,右移多少位,就把最高位右边的第x位设置为1;第二次,就把两个为1的右边xx位再设置为1;第n次,就把上一步出现的1右边xxxx位置为1;
这样执行完,原来是1000000,变成了1111111,最后加1,就变成2的整数次方数了。之所以先减一是因为有可能本身就是最小2的幂次方整数。
2.Put方法
put方法的核心就是 putVal ,源码和执行过程如下。
//实现 put 和相关方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i; //如果table为空或者长度为0,则进行resize()(扩容)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; //确定插入table的位置,算法是上面提到的 (n - 1) & hash,在 n 为 2 的时候,相当于取模操作
if ((p = tab[i = (n - 1) & hash]) == null)
//找到key值对应的位置并且是第一个,直接插入
tab[i] = newNode(hash, key, value, null); //在table的 i 的位置发生碰撞,分两种情况
//1、key值是一样的,替换value值
//2、key值不一样的
//而key值不一样的有两种处理方式:1、存储在 i 的位置的链表 2、存储在红黑树中
else {
Node<K,V> e; K k; //第一个Node的hash值即为要加入元素的hash
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 {
//如果不是TreeNode的话,即为链表,然后遍历链表
for (int binCount = 0; ; ++binCount) { //链表的尾端也没有找到key值相同的节点,则生成一个新的Node
//并且判断链表的节点个数是不是到达转换成红黑树的上界达到,则转换成红黑树
if ((e = p.next) == null) { //创建链表节点并插入尾部
p.next = newNode(hash, key, value, null); //超过了链表的设置长度(默认为8)则转换为红黑树
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;
}
} //如果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;
}
为了方便理解,配图
putVal中有一段代码提到了resize(),也就是扩容,我们来看下源码
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0; //判断Node的长度,如果不为零
if (oldCap > 0) {
//判断当前Node的长度,如果当前长度超过 MAXIMUM_CAPACITY(最大容量值)
if (oldCap >= MAXIMUM_CAPACITY) {
//新增阀值为 Integer.MAX_VALUE
threshold = Integer.MAX_VALUE;
return oldTab;
} //如果小于这个 MAXIMUM_CAPACITY(最大容量值),并且大于 DEFAULT_INITIAL_CAPACITY (默认16)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
//进行2倍扩容
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
//指定新增阀值
newCap = oldThr; //如果数组为空
else { // zero initial threshold signifies using defaults
//使用默认的加载因子(0.75)
newCap = DEFAULT_INITIAL_CAPACITY;
//新增的阀值也就为 16 * 0.75 = 12
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);
} //扩容后的新增阀值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//扩容后的Node数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab; //如果数组不为空,将原数组中的元素放入扩容后的数组中
if (oldTab != null) {
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; //哈希值和原数组长度进行&操作,为0则在原数组的索引位置
//非0则在原数组索引位置+原数组长度的新位置
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;
为什么会hash冲突?
就是根据key即经过一个函数f(key)得到的结果的作为地址去存放当前的key,value键值对(这个是hashmap的存值方式),但是却发现算出来的地址上已经有数据。这就是所谓的hash冲突。
hash冲突的几种情况:
1两个节点的key值相同(hash值一定相同),导致冲突
2 两个节点的key值不同,由于hash函数的局限性导致hash值相同,导致冲突
3 两个节点的key值不同,hash值不同,但hash值对数组长度取模后相同,导致冲突
如何解决hash冲突?解决hash冲突的方法主要有两种,一种是开放寻址法,另一种是链表法 。
开放寻址法--线性探测
开放寻址法的原理很简单,就是当一个Key通过hash函数获得对应的数组下标已被占用的时候,我们可以寻找下一个空档位置
比如有个Entry6通过hash函数得到的下标为2,但是该下标在数组中已经有了其它的元素,那么就向后移动1位,看看数组下标为3的位置是否有空位
比如有个Entry6通过hash函数得到的下标为2,但是该下标在数组中已经有了其它的元素,那么就向后移动1位,看看数组下标为3的位置是否有空位
但是下标为3的数组也已经被占用了,那么久再向后移动1位,看看数组下标为4的位置是否为空
数组下标为4的位置还没有被占用,所以可以把Entry6存入到数组下标为4的位置。这就是开放寻址的基本思路,寻址的方式有很多种,这里只是简单的一个示例
链表法
链表法也正是被应用在了HashMap中,HashMap中数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。每一个Entry对象通过next指针指向它的下一个Entry节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可
JDK7和JDK8中的HashMap都是线程不安全的,主要体现在哪些方面?
只要是对于集合有一定了解的一定都知道HashMap是线程不安全的,我们应该使用ConcurrentHashMap。但是为什么HashMap是线程不安全的呢,之前面试的时候也遇到到这样的问题,但是当时只停留在***知道是***的层面上,并没有深入理解***为什么是***。于是今天重温一个HashMap线程不安全的这个问题。
首先需要强调一点,HashMap的线程不安全体现在会造成死循环、数据丢失、数据覆盖这些问题。其中死循环和数据丢失是在JDK1.7中出现的问题,在JDK1.8中已经得到解决,然而1.8中仍会有数据覆盖这样的问题。
扩容引发的线程不安全
HashMap
的线程不安全主要是发生在扩容函数中,即根源是在transfer函数中,JDK1.7中HashMap
的transfer
函数如下:
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];
newTable[i] = e;
e = next;
}
}
}
这段代码是HashMap的扩容操作,重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。理解了头插法后再继续往下看是如何造成死循环以及数据丢失的。
扩容造成死循环和数据丢失的分析过程
假设现在有两个线程A、B同时对下面这个HashMap进行扩容操作:
正常扩容后的结果是下面这样的:
但是当线程A执行到上面transfer
函数的第11行代码时,CPU时间片耗尽,线程A被挂起。即如下图中位置所示:
此时线程A中:e=3、next=7、e.next=null
当线程A的时间片耗尽后,CPU开始执行线程B,并在线程B中成功的完成了数据迁移
重点来了,根据Java内存模式可知,线程B执行完数据迁移后,此时主内存中newTable和table都是最新的,也就是说:7.next=3、3.next=null。
随后线程A获得CPU时间片继续执行newTable[i] = e,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下:
接着继续执行下一轮循环,此时e=7,从主内存中读取e.next时发现主内存中7.next=3,于是乎next=3,并将7采用头插法的方式放入新数组中,并继续执行完此轮循环,结果如下:
执行下一次循环可以发现,next=e.next=null,所以此轮循环将会是最后一轮循环。接下来当执行完e.next=newTable[i]即3.next=7后,3和7之间就相互连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中,执行结果如下图所示:
上面说了此时e.next=null即next=null,当执行完e=null后,将不会进行下一轮循环。到此线程A、B的扩容操作完成,很明显当线程A执行完后,HashMap中出现了环形结构,当在以后对该HashMap进行操作时会出现死循环。
并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。
JDK1.8中的线程不安全
根据上面JDK1.7出现的问题,在JDK1.8中已经得到了很好的解决,如果你去阅读1.8的源码会发现找不到transfer函数,因为JDK1.8直接在resize函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法。
为什么说JDK1.8会出现数据覆盖的情况喃,我们来看一下下面这段JDK1.8中的put操作代码:
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)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素
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;
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) {
p.next = newNode(hash, key, value, null);
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;
}
其中第六行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
除此之前,还有就是代码的第38行处有个++size,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。
结论
HashMap的线程不安全主要体现在下面两个方面:
1.在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。
2.在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。
Java8 HashMap详解(转)的更多相关文章
- Java8 HashMap详解
Java8 HashMap Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成. 根据 Java7 HashMap 的介绍,我们知道,查找的 ...
- 【转】 java中HashMap详解
原文网址:http://blog.csdn.net/caihaijiang/article/details/6280251 java中HashMap详解 HashMap 和 HashSet 是 Jav ...
- java中HashMap详解(转)
java中HashMap详解 博客分类: JavaSE Java算法JDK编程生活 HashMap 和 HashSet 是 Java Collection Framework 的两个重要成 ...
- java集合(2)- java中HashMap详解
java中HashMap详解 基于哈希表的 Map 接口的实现.此实现提供所有可选的映射操作,并允许使用 null 值和 null 键.(除了非同步和允许使用 null 之外,HashMap 类与 H ...
- Java8 特性详解(一) Lambda
为什么要使用lambda表达式 从函数式接口说起 理解Functional Interface(函数式接口)是学习Java8 lambda表达式的关键所在. 函数式接口的定义其实很简单:任何接口,如果 ...
- Java 中HashMap 详解
本篇重点: 1.HashMap的存储结构 2.HashMap的put和get操作过程 3.HashMap的扩容 4.关于transient关键字 HashMap的存储结构 1. HashMap 总体是 ...
- Java8特性详解 lambda表达式 Stream
1.lambda表达式 Java8最值得学习的特性就是Lambda表达式和Stream API,如果有python或者javascript的语言基础,对理解Lambda表达式有很大帮助,因为Java正 ...
- Java8特性详解 lambda表达式 Stream【转】
本文转自http://www.cnblogs.com/aoeiuv/p/5911692.html 1.lambda表达式 Java8最值得学习的特性就是Lambda表达式和Stream API,如果有 ...
- 转发 java数据结构之hashMap详解
概要 这一章,我们对HashMap进行学习.我们先对HashMap有个整体认识,然后再学习它的源码,最后再通过实例来学会使用HashMap.内容包括:第1部分 HashMap介绍第2部分 HashMa ...
随机推荐
- MySQL/mariadb知识点总结
1.mysql/mariadb知识点总结:事务相关概念(事务总结-1) http://www.zsythink.net/archives/1204 2.mysql/mariadb知识点总结:事务控制语 ...
- vue组件结构
1.组件结构 2.项目结构
- 安装vue-cli脚手架项目
1,下载安装node.官网下载后,重新打开cmd再打开. 命令:node -v检测版本. 2,安装vue-cli: 命令:npm install --global vue-cli .根据安装情况,会 ...
- 6364. 【NOIP2019模拟2019.9.20】养马
题目描述 题解 一种显然的水法:max(0,-(点权-边权之和*2)) 这样会挂是因为在中途体力值可能会更小,所以考虑求走完每棵子树所需的至少体力值 考虑从子树往上推求出当前点的答案 设每棵子树从根往 ...
- lua脚本入门
在网上下载一些工程,里边常常存在.lua .sh .in .cmake .bat等文件 今天专门查了一下相关文件的作用 .sh 通常是linux.unix系统下的脚本文件(文本文件),用于调用默认的s ...
- Activiti组任务(十)
1 Candidate-users 候选人 1.1需求 在流程定义中在任务结点的 assignee 固定设置任务负责人,在流程定义时将参与者固定设置在.bpmn 文件中,如果临时任务负责人变更则需要修 ...
- [CF1054C]Candies Distribution
题目:Candies Distribution 传送门:http://codeforces.com/problemset/problem/1054/C 分析: 方法一: 1)类似拓扑排序的做法. 2) ...
- ERROR 1366 (HY000): Incorrect string value:MySQL数据库、表的字符集为GBK
mysql> update userinfo set MEDIASOURCE = 'CS02-北京' where IMPORTNO = 'IMP201640613101206';ERROR 13 ...
- C# 防火墙操作之特定程序
将特定程序加入防火墙组,与将特定端口加入防火墙流程类似.详情见“C# 防火墙操作之特定端口”.其主要代码为: /// <summary> /// 允许应用程序通过防火墙 /// </ ...
- javamail 附件以及正文加图片
直接上代码 import java.io.IOException; import java.io.InputStream; import java.util.Date; import java.uti ...