每天都在用 Map,这些核心技术你知道吗?

85 阅读6分钟
HashMap
HashMap 是我们经常会用到的集合类,JDK 1.7 之前底层使用了数组加链表的组合结构,如下图所示:
新添加的元素通过取模的方式,定位 Table 数组位置,然后将元素加入链表头部,这样下次提取时就可以快速被访问到。
访问数据时,也是通过取模的方式,定位数组中的位置,然后再遍历链表,依次比较,获取相应的元素。
如果 HasMap 中元素过多时,可能导致某个位置上链表很长。原本 O(1) 查找性能,可能就退化成 O(N),严重降低查找效率。
为了避免这种情况,当 HasMap 元素数量满足以下条件时,将会自动扩容,重新分配元素。
// size:HashMap 中实际元素数量
//capacity:HashMap 容量,即 Table 数组长度,默认为:16
//loadFactor:负载因子,默认为:0.75
size>=capacity*loadFactor
HasMap 将会把容量扩充为原来的两倍,然后将原数组元素迁移至新数组。
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;
// 插入到链表头结点,
newTable = e; e = next; } }}
旧数组元素迁移到新数组时,依旧采用『头插入法』,这样将会导致新链表元素的逆序排序。
多线程并发扩容的情况下,链表可能形成死链(环形链表)。一旦有任何查找元素的动作,线程将会陷入死循环,从而引发 CPU 使用率飙升。
网上详细分析死链形成的过程比较多,这里就不再详细解释,大家感兴趣可以阅读以下@陈皓的文章。
文章地址:https://coolshell.cn/articles/9606.html
JDK1.8 改进方案
JDK1.8 HashMap 底层结构进行彻底重构,使用数组加链表/红黑树方式这种组合结构。
新元素依旧通过取模方式获取 Table 数组位置,然后再将元素加入链表尾部。一旦链表元素数量超过 8 之后,自动转为红黑树,进一步提高了查找效率。
面试题:为什么这里使用红黑树?而不是其他二叉树呢?
由于 JDK1.8 链表采用『尾插入』法,从而避免并发扩容情况下链表形成死链的可能。
那么 HashMap 在 JDK1.8 版本就是并发安全的吗?
其实并没有,多线程并发的情况,HashMap 可能导致丢失数据。
下面是一段 JDK1.8 测试代码:
在我的电脑上输出如下,数据发生了丢失:
从源码出发,并发过程数据丢失的原因有以下几点:
并发赋值时被覆盖
并发的情况下,一个线程的赋值可能被另一个线程覆盖,这就导致对象的丢失。
size 计算问题
每次元素增加完成之后,size 将会加 1。这里采用 ++i方法,天然的并发不安全。
对象丢失的问题原因可能还有很多,这里只是列举两个比较的明显的问题。
当然 JDK1.7 中也是存在数据丢失的问题,问题原因也比较相似。
一旦发生死链的问题,机器 CPU 飙升,通过系统监控,我们可以很容易发现。
但是数据丢失的问题就不容易被发现。因为数据丢失环节往往非常长,往往需要系统运行一段时间才可能出现,而且这种情况下又不会形成脏数据。只有出现一些诡异的情况,我们才可能去排查,而且这种问题排查起来也比较困难。
SynchronizedMap
对于并发的情况,我们可以使用 JDK 提供 SynchronizedMap 保证安全。
SynchronizedMap 是一个内部类,只能通过以下方式创建实例。
Map m = Collections.synchronizedMap(
new
HashMap(...));
SynchronizedMap 源码如下:
每个方法内将会使用 synchronized 关键字加锁,从而保证并发安全。
由于多线程共享同一把锁,导致同一时间只允许一个线程读写操作,其他线程必须等待,极大降低的性能。
并且大多数业务场景都是读多写少,多线程读操作本身并不冲突,SynchronizedMap 极大的限制读的性能。
所以多线程并发场景我们很少使用 SynchronizedMap 。
ConcurrentHashMap
既然多线程共享一把锁,导致性能下降。那么设想一下我们是不是多搞几把锁,分流线程,减少锁冲突,提高并发度。
ConcurrentHashMap 正是使用这种方法,不但保证并发过程数据安全,又保证一定的效率。
JDK1.7
JDK1.7 ConcurrentHashMap 数据结构如下所示:
Segament 是一个ConcurrentHashMap内部类,底层结构与 HashMap 一致。另外Segament 继承自 ReentrantLock,类图如下:
当新元素加入 ConcurrentHashMap 时,首先根据 key hash 值找到相应的 Segament。接着直接对 Segament 上锁,若获取成功,后续操作步骤如同 HashMap。
由于锁的存在,Segament 内部操作都是并发安全,同时由于其他 Segament 未被占用,因此可以支持 concurrencyLevel 个线程安全的并发读写。
size 统计问题
虽然 ConcurrentHashMap 引入分段锁解决多线程并发的问题,但是同时引入新的复杂度,导致计算 ConcurrentHashMap 元素数量将会变得复杂。
由于 ConcurrentHashMap 元素实际分布在 Segament 中,为了统计实际数量,只能遍历 Segament数组求和。
为了数据的准确性,这个过程过我们需要锁住所有的 Segament,计算结束之后,再依次解锁。不过这样做,将会导致写操作被阻塞,一定程度降低 ConcurrentHashMap性能。
所以这里对 ConcurrentHashMap#size 统计方法进行一定的优化。
Segment 每次被修改(写入,删除),都会对 modCount(更新次数)加 1。只要相邻两次计算获取所有的 Segment modCount总和一致,则代表两次计算过程并无写入或删除,可以直接返回统计数量。
如果三次计算结果都不一致,那没办法只能对所有 Segment 加锁,重新计算结果。
这里需要注意的是,这里求得 size 数量不能做到 100% 准确。这是因为最后依次对 Segment 解锁后,可能会有其他线程进入写入操作。这样就导致返回时的数量与实际数不一致。
不过这也能被接受,总不能因为为了统计元素停止所有元素的写入操作。
性能问题
想象一种极端情况的,所有写入都落在同一个 Segment中,这就导致ConcurrentHashMap 退化成 SynchronizedMap,共同抢一把锁。