底层实现: 数组+链表+红黑树。
1. 确定哈希桶数组索引位置
HashMap 定位数组索引位置:<br>这里的Hash算法本质上就是三步:取key的hashCode值、高位运算。<br>
问:为什么计算出hascode 后不直接与数组长度取模?<br>模运算的消耗还是比较大的!
3. 扩容机制- put 方法(得物)
为什么需要扩容?
扩容的负载因子是 0.75,即当数组容量cap大小达到 cap x 0.75 就会扩容 2 倍
据我的经验,日常使用中,大部分使用到 HashMap 的情况,大小都不会超过 1000
Hash 环问题
java7 中put 操作 的 resize 容易出现 死循环,<br>会在数组上形成一个环形链表。
因为 jdk7 在扩容的时候认为新插入的元素访问概率更高,<br>所以在扩容时,会把链表翻转。所以在多线程下就会形成hash环
单链表如何翻转?(不借助其它数据结构)
java8 中是如何解决 hash 环问题的?
jdk 7 头插法
jdk 7 头插法为什么会产生hash 环?
jdk 8 尾插法 解决hash 环
线程安全的HashMap(并发包提供的,不属于Map体系)
ConcurrentHashMap
java1.7 和 java1.8实现不同
java1.7
底层实现
采用分段锁,每一段数据分配一个Segment(锁),<br>当线程占用其中一个Segment时,其他线程可正常访问其他段数据<br>Segment 继承了ReentrantLock,是一个AQS对象
虽然已经解决了HashMap并发安全问题,但是查询遍历链表效率太低。
java 1.8
底层实现
抛弃了原有的 Segment 分段锁,而采用了 CAS + volatile 来保证并发安全性。
8中如何解决并发的
总结:
HashMap 为什么在链表长度为 8 的时候转红黑树,为啥不能是 9 是 10?
源码中有一段注释, 说的很明白:<br>hashcode碰撞次数的泊松分布有关,<br>在负载因子0.75(HashMap默认)的情况下,单个hash槽内元素个数为8的概率小于百万分之一<br>
<ul><li><b>1、table 的初始化时机是什么时候,初始化的 table.length 是多少、阀值(threshold)是多少,实际能容下多少元素</b></li><li><b><br></b></li><li><b>2、什么时候触发扩容,扩容之后的 table.length、阀值各是多少?</b></li><li><b><br></b></li><li><b>3、table 的 length 为什么是 2 的 n 次幂?<font color="#f15a23">为什么为什么要先高16位异或低16位再取模运算?</font></b></li><li><b><br></b></li><li><b>4、求索引的时候为什么是:h&(length-1),而不是 h&length,更不是 h%length</b></li><li><b><br></b></li><li><b>5、 Map map = new HashMap(1000); 当我们存入多少个元素时会触发map的扩容; Map map1 = new HashMap(10000); 我们存入第 10001个元素时会触发 map1 扩容吗? </b><font color="#f15a23">会的,动态扩容! 最大容量为: 1024*0.75 。</font></li><li><b><br></b></li><li><b>6、为什么加载因子的默认值是 0.75,并且不推荐我们修改<br><br>7. HashMap 多线程会有什么问题?<br><br>8. </b>链表转红黑树的阈值为什么选8?为什么用红黑树做优化?</li></ul>
1.<br>table 的初始化时机是什么时候<br><b><font color="#f15a23"> 一般情况下,在第一次 put 的时候,调用 resize 方法进行 table 的初始化(懒初始化,懒加载思想在很多框架中都有应用!)</font></b><br> <br>初始化的 table.length 是多少、阀值(threshold)是多少,实际能容下多少元素<br><b><font color="#f15a23"> 默认情况下,table.length = 16; <br> 默认情况下,threshold = 12; </font></b><br> 默认情况下,能存放 12 个元素,当存放第 13 个元素后进行扩容
2.<br>什么时候触发扩容,扩容之后的 table.length、阀值各是多少<br> 当 <b><font color="#f15a23">size > threshold</font></b> 的时候进行扩容<br> 扩容之后的 table.length = 旧 table.length * 2,<br> 扩容之后的 threshold = 旧 threshold * 2<br>
// oldCap左移一位; newCap = 16 << 1 = 32;
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && <b><font color="#f15a23">// oldCap左移一位; newCap = 16 << 1 = 32;</font></b><br> oldCap >= DEFAULT_INITIAL_CAPACITY)<br> newThr = oldThr << 1; // double threshold<b><font color="#f15a23"> // newThr = 12 << 1 = 24;</font></b>
3.<br>table 的 length 为什么是 2 的 n 次幂?<br><b><font color="#f15a23"> 为了利用位运算 & 求 key 的下标(</font></b><font color="#c41230">我们可以发现,只有 & 数是1时,& 运算的结果与被 & 数一致</font><b><font color="#f15a23">)<br></font></b>1&1=1;<br>0&1=0;<br>1&0=0;<br>0&0=0;<br><br>为什么为什么要先高16位异或低16位再取模运算? <br>右移·16 位 再异或运算,就能保证高低位都参与到取模运算中;<br>而取模运算 是通过 hash&(length-1)实现的!<br><b><font color="#c41230">只是为了降低hash冲突的几率(看图)</font></b><br><br>
4.<br>求索引的时候为什么是:hash&(length-1),而不是 hash&length,更不是 hash%length<br><b><font color="#f15a23">hash%length 效率不如位运算快<br>hash%length 会提高碰撞几率,导致 table 的空间得不到更充分的利用、降低 table 的操作效率<br></font></b><br>原理:<br>为了利用位运算 & 求 key 的下标(我们可以发现,只有 & 数是1时,& 运算的结果与被 & 数一致)<br>1&1=1;<br>0&1=0;<br>1&0=0;<br>0&0=0;<br>
5.<br><b><font color="#c41230">数组大小需要保证是 2 的 N 次幂!!!</font></b><br>从构造方法的逻辑可以看出,HashMap 并不是直接使用外部传递进来的 initialCapacity,而是经过了 tableSizeFor() 方法的处理,再赋值到 threshole 上。<br><br>在 tableSizeFor() 方法中,<b><font color="#f15a23">通过逐步位运算,就可以让返回值,保持在 2 的 N 次幂。</font></b>以方便在扩容的时候,快速计算数据在扩容后的新表中的位置。<br><br>那么当我们从外部传递进来 1w 时,实际上经过 tableSizeFor() 方法处理之后,就会变成 2 的 14 次幂 16384,再算上负载因子 0.75f,实际在不触发扩容的前提下,可存储的数据容量是 12288(16384 * 0.75f)。<br>
6.<br>为什么加载因子的默认值是 0.75,并且不推荐我们修改<br> 如果loadFactor太小,那么map中的table需要不断的扩容,扩容是个耗时的过程<br> 如果loadFactor太大,那么map中table放满了也不不会扩容,导致冲突越来越多,解决冲突而起的链表越来越长,效率越来越低<br> 而 0.75 这是一个折中的值,是一个比较理想的值<br>
7.<br>HashMap在并发编程环境下有什么问题啊?<br><br>(1)多线程扩容,引起的死循环问题 --》 1.7 头插法<br>(2)<b><font color="#f15a23">多线程put的时候可能导致元素丢失</font></b><br>(3)put非null元素后get出来的却是null<br>
8. 链表转红黑树的阈值为什么选8?为什么用红黑树做优化?<br><b><font color="#f15a23">采用均匀 Hash 算法的话, 桶的长度超过8的概率非常非常小</font></b><br><br>通过源码我们得知HashMap源码作者通过<b><font color="#f15a23">泊松分布</font></b>算出,<b>当桶中结点个数为8时,出现的几率是亿分之6的</b>,因此常见的情况是桶中个数小于8的情况,此时链表的查询性能和红黑树相差不多,因为转化为树还需要时间和空间,所以此时没有转化成树的必要。<br><br><font color="#c41230">既然个数为8时发生的几率这么低,我们为什么还要当链表个数大于8时来树化来优化这几乎不会发生的场景呢?</font><br><br>首先我们要知道亿分之6这个几乎不可能的概率是建立在什么情况下的 答案是:<b><font color="#f15a23">建立在良好的hash算法情况下</font></b>,例如String,Integer等包装类的hash算法、<b>如果一旦发生桶中元素大于8,说明是不正常情况,可能采用了冲突较大的hash算法</b>,此时桶中个数出现超过8的概率是非常大的,可能有n个key冲突在同一个桶中,此时再看链表的平均查询复杂度和红黑树的时间复杂度,就知道为什么要引入红黑树了,<br><br>举个例子,若hash算法写的不好,一个桶中冲突1024个key,使用链表平均需要查询512次,但是红黑树仅仅10次,红黑树的引入保证了在大量hash冲突的情况下,HashMap还具有良好的查询性能<br><br>
ConcurrentHashMap:<br><br>0. HashMap 并发情况下会有什么线程安全问题?<br><br>1. ConcurrentHashMap的实现原理<br><br>2. ConcurrentHashMap1.7和1.8的区别?<br>3. ConcurrentHashMap使用什么技术来保证线程安全<br>4. ConcurrentHashMap的put()方法<br><br>5. ConcurrentHashmap 不支持 key 或者 value 为 null 的原因?<br>6. put()方法如何实现线程安全呢?<br>7. ConcurrentHashMap扩容机制<br>8. ConcurrentHashMap的get方法是否要加锁,为什么?<br><br>9. 为什么使用ConcurrentHashMap<br>10. ConcurrentHashMap迭代器是强一致性还是弱一致性?HashMap呢?<br><br>11. 经典面试题:为什么 ConcurrentHashMap 的读操作不需要加锁?<br><br><br><br><br><br>
0. HashMap 并发情况下会有什么线程安全问题?
JDK7: 死循环-> 头插法
put 时 如果产生扩容, 并发可能会丢失数据.
1. ConcurrentHashMap的实现原理:<br><br>
JDK 7:Segment(分段锁)+HashEntry
使用分段锁,将数据分成许多个 Segment, 每个 Segment 单独加锁, 每个 Segment 里面都对应一个链表
好处:
对一个 Segment 加锁不会影响到的其它的 Segment 的读写, 理论上最大可以支持 Segment 数量的并发
坏处:
1. 要经过两次 Hash ,第一次找到对应的 Segment,进行加锁,第二次 Hash 定位到元素所在链表<br>2. 查询遍历链表效率太低
JDK8: CAS+volatile +sychronized
JDK8 中的 ConcurrentHashMap 参考了 HashMap 的实现,基本实现了无锁操作,大量运用的 CAS 和 voltile 实现并发读写
JDK8 中放弃了 Segment 采用了 Node 这个数据结构<br><br>每个键值对(key-value)都存储在一个Node中。同时Node也有一些子类,TreeNodes用于树结构中(当链表长度大于8时转化为红黑树);TreeBins用于维护TreeNodes。当链表转树时,用于封装TreeNode。也就是说,ConcurrentHashMap的红黑树存放的是TreeBin,而不是treeNode<br>————————————————<br>版权声明:本文为CSDN博主「Coding-ls」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。<br>原文链接:https://blog.csdn.net/programerxiaoer/article/details/80040090<br>
<b><font color="#c41230">4. ConcurrentHashMap的put()方法?</font></b>
1:首先判断是否初始化,如果没有初始化则进入$3进行初始化工作 <br>2:如果已经初始化了,进入无限循环,$4原子判断key对应的数组下标是否有值了(<b><font color="#f15a23">如果下标为null 则可以插入,这也是为什么put 不支持 key 或者 value 为null的原因</font></b>)<br> 3:如果key对应的下标没有值,则通过$5:CAS原理插入,插入成功则退出循环,插入失败则继续循环,所以这种无锁的机制都是利用无限循环+CAS操作。<br> 4:如果key对应的下标已经存在值,判断此时hash==MOVED,则进入$6帮助扩容。<br> 5:如果key对应的下标已经存在值,但是hash!=MOVED,则需要$7对数组的这个下标进行加锁了,以保证线程的安全。<br> 6:如果数组的这个下标是一个链表,则对操作链表(判断链表用hash>=0)<br> 7:如果数组的这个下标是一个红黑树,则操作红黑树。<br> 8:插入成功后,如果链表的长度已经达到了红黑树的阀门8,则首先判断此时数组的长度是否大于64,如果小于64则进行扩容,如果大于等于64则链表变成红黑树<br> 9:判断容器是否扩容<br><br>原文網址:https://kknews.cc/code/eymyxjn.html
初始化数组
无限循环CAS 判断数组下标是否为null,为null就进行CAS 插入
如果数组下标有值,要么在链表加入,要么在红黑树加入,这个操作是 sychronized 来保证的
链表长度超过8,并且 数组长度大于 64 就会链表转变为红黑树
5.ConcurrentHashmap 不支持 key 或者 value 为 null 的原因?
因为concurrenthashmap它们是用于多线程的,并发的 ,如果map.get(key)得到了null,不能判断到底是映射的value是null,还是因为没有找到对应的key而为空,<br>而用于单线程状态的hashmap却可以用containKey(key) 去判断到底是否包含了这个null。<br>
11.经典面试题:为什么 ConcurrentHashMap 的读操作不需要加锁?
ConcurrentHashMap get 操作不加锁是它高效率的原因<br>为什么?<br>1. Node 的成员变量 val 是用 volatile 修饰的,可以保证在多个线程中的内存可见性<br>2. ConcurrentHashMap 中的数组也是 volatile 修饰的, 可以保证数组扩容时的可见性<br><br>
引申 volatile 面试发问?
有序性
可见性
底层通过内存屏障来实现
内存屏障就是一组处理器指令,<br>用于实现对内存操作的顺序限制
happend-before 原则:
通俗的讲就是: a happen-before b,则a所做的任何操作对b是可见的。
volatile 总线风暴知道不?