HashMap和ConcurrentHashMap底层解析(jdk1.7/1.8)
HashMap
HashMap 底层是基于 数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同
jdk1.7中:
内部结构:
从图中可以看到,在hashmap内部真正用于存储
真正存放数据的是 Entry
Entry 是 HashMap 中的一个内部类,成员变量包括:key、value、next、hashcode
存取逻辑
存放逻辑:
• 如果 key 为空,则 put 一个空值进去。(也就是说允许null作为键值)
• 根据 key 计算出 hashcode。
• 根据计算出的 hashcode 定位出所在桶。
• 如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。
• 如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置。
取值逻辑:
• 首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。
• 判断该位置是否为链表。
• 不是链表就根据 key、key 的 hashcode 是否相等来返回值。
• 为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。
啥都没取到就直接返回 null 。
给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
jdk1.8做出的改动:
1.链表结构
先看一下jdk8中的hashmap结构图
1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)。
区别在于,插入时会多出以下几步:
- 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
- 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
接着判断当前链表的大小是否大于预设的阈值(默认是8),大于时就要转换为红黑树(若此时数组长度没有达到64,会先进行扩容,扩容能解决就不用转红黑树了)。
2. 懒加载提升性能
jdk8中是懒加载模式,new的时候数组是不创建的,当put进第一个元素时才会调用resize()创建长度为16的数组
扩展:
1.hashmap的线程不安全性
Hashmap的线程不安全性主要体现在resize()扩容方法上
第一种是出现覆盖:
假设现在有线程A 和线程B 共同对同一个HashMap进行PU操作,假设A和B插入的Key-Value中key的hashcode是相同的,这说明该键值对将会插入到Table的同一个下标的,也就是会发生哈希碰撞,此时HashMap按照平时的做法是形成一个链表(若超过八个节点则是红黑树),现在我们插入的下标为null(Table[i]==null)则进行正常的插入,
此时线程A进行到了这一步正准备插入,这时候线程A堵塞,线程B获得运行时间,进行同样操作,也是Table[i]==null , 此时它直接运行完整个PUT方法,成功将元素插入. 随后线程A获得运行时间接上上面的判断继续运行,进行了Table[i]==null的插入(此时其实应该是Table[i]!=null的操作,因为前面线程B已经插入了一个元素了),这样就会直接把原来线程B插入的数据直接覆盖了,如此一来就造成了线程不安全问题.
第二种(jdk1.8已经不会出现了)是并发操作容易在一个桶上形成环形链表,这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环:
扩容代码:
假设有两个线程同时对hashmap扩容:
1.两个线程同时生成新的hash数组;
2.都有next指针指向entry2
假设线程1执行到此时发生阻塞,线程2执行,成功把链表迁移到新的数组上
此时的线程1,又结束阻塞,打算继续执行,把链表迁移到自己创建的数组上
线程1 将Entry3放到新数组,同时获取next指向的元素entry2,将其插在entry3的首部 ,next指向entry2的next元素–Entry3
当再一次把Entry3插入链表的头部时,一个环就出现了
jdk1.8把扩容时的头插改成了尾插,不会再出现这种环的问题了(仍是非线程安全的。)
2.hashMap的寻址方式
默认的hash寻址计算方式是:hash = keyhash&(length-1)
e.g. tableSize = 4 二进制为100
那么计算出来的hash值应该为keyhash&(011)
如果扩容,tableSize=tableSize*2=8,上述所说的jdk1.7中,会重新计算每一个key对应的hash值:hash=keyhash&(1000-1)=keyhash&(0111)
在jdk1.8中,hashmap的扩容时的hash计算方式进行了修改,不再是直接重新计算hash值
而是,而是将扩容前的hash和之前的容积做&运算
因为扩容代表tablesize左移
e.g.(100–>1000)
oldhash=(keyhash&11)
newhash=(keyhash&111)=(oldhash&100)
而100就是扩容前的tableSize
注意:
这里的keyhash也不是直接调用key的hashcode得到的,而是要经过二次加工:
将hashcode无符号右移位,与原hashcode进行异或因为若length比较小,这样高位全为0,如果支取后四位(低位)的话,这个时候产生"碰撞"的几率就非常大,这样高低16位的异或相当于扰动函数
为了混合原始哈希码的高位和低位,以此来加大低位随机性
concurrentHashMap
HashMap是线程不安全的容器,在多线程环境下可以选择HashTable和ConcurrentHashmap,HashTable实现方式和HashMap基本没有差别,只是在所有的方法上加上了Synchronized关键字
ConcurrentHashMap 的设计与实现非常精巧,大量的利用了 volatile,final,CAS 等 lock-free 技术来减少锁竞争对于性能的影响。
jdk1.7:
在 JDK1.7 中 ConcurrentHashMap 采用了数组 + Segment + 分段锁的方式实现。
1.Segment (分段锁)
ConcurrentHashMap 中的分段锁称为 Segment,它即类似于 HashMap 的结构,即内部拥有一个 Entry 数组,数组中的每个元素又是一个链表,同时又是一个 ReentrantLock(Segment 继承了 ReentrantLock)。
2. 内部结构
ConcurrentHashMap 使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。(理论上下图可以支持三个线程同时访问)
从上面的结构我们可以了解到,ConcurrentHashMap 定位一个元素的过程需要进行两次 Hash 操作。
第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部。
坏处
1.这一种结构的带来的副作用是 Hash 的过程要比普通的 HashMap 要长
2.Segment大小没有自增机制,并发度会随着线程数量增加而降低
3.并不是懒惰初始化的
好处
写操作的时候可以只对元素所在的 Segment 进行加锁即可,不会影响到其他的 Segment,这样,在最理想的情况下,ConcurrentHashMap 可以最高同时支持 Segment 数量大小的写操作(刚好这些写操作都非常平均地分布在所有的 Segment 上)。
所以,通过这一种结构,ConcurrentHashMap 的并发能力可以大大的提高。
jdk1.8
Jdk1.8: ConcurrentHashmap 和Hashmap 一样是懒加载,当创建时不会直接创建数组,而是在第一次put的时候初始化(默认大小一样为16),注意:如果传入了参数,例如32 ,在1.7中会创建32大小的数组,在1.8中会创建64的
get方法和上述的流程基本一致,有少许区别:若遍历到的数组节点中存放的Entry中hash为负数,这代表数组正在扩容中,这时会调用e.find去新的数组中查找/也可能是变成了红黑树,这时也会调用新的查找方法 get方法中没有任何锁
put方法:
• 检查是键或值否为null,为null则报错
• 检查hash数组是否为空,如果为空,通过CAS的方法创建hash表,Sizectl-1表示其它线程正在初始化
• 根据 key 计算出 hashcode,根据计算出的 hashcode 定位出所在桶。
• 如果桶是空的,说明当前位置没有数据存入;通过CAS新增一个 Node 对象写入当前位,结束。
• 桶不为null,检查头节点的hash是否为-1,若为-1则表明hash表正在扩容中,那么会锁住当前链表,帮忙扩容
• 到这说明hash表不在扩容中且发生了hash冲突,在这里才会使用Synchronized锁住链表头节点
• 检查头节点的hash值是否大于0(大于0表示为链表,-2表示为红黑树)
• 若大于0,遍历链表里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖(或者不覆盖),并返回原来的值。如果遍历到最后一个节点了,创建一个新的Node对象挂在链表尾(遍历时会记录链表长度bincount)
• 若小于0,且判断节点是InstanceOf TreeBin(TreeBin是红黑树的节点)为真:调用红黑树的put方法
• 检查bincount是否大于8,若大于8,则检查hash数组长度是否大于64,若小于64,则进行扩容
• 若hash数组长度大于等于64,则将链表转化为红黑树
• 增加hashmap中节点数量的计数值,用于后续的是否扩容的判断(扩容是根据hashmap中的元素数量是否大于容量*负载因子判断的,不是已使用的桶的数量)这里的累加用的是Longadder方式保证线程安全