HashMap 和 ConcurrentHashMap

107 阅读11分钟

HashMap

基于 Map 接口实现,非线程安全

JDK1.7的 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。

JDK1.8 以后的 HashMap 是由数组 + 链表 + 红黑树组成的在解决哈希冲突时,若当链表长度大于等于8,且数组长度大于等于64时,将链表转化为红黑树,以减少搜索时间。若当前数组长度小于64,则会先对数组进行扩容。红黑树查找时间复杂度为O(logn)。

数组中的每个元素称为

为什么 JDK1.8中要使用红黑树

如果是链表,当某个桶冲突过多的时候,链表就会变得很长,此时再进行 put 和 get 操作时间复杂度比较高。

如果使用二叉树,当出现极端情况如父节点都比子节点小或大时,二叉树又有可能退化成链表

如果使用平衡二叉树 AVL,由于 AVL 严格要求左右子树高度差最多为1,每次插入都要进行左旋和右旋插入效率低

所以引入红黑树,红黑树不会像 AVL 树一样追求绝对的平衡,它的插入最多两次旋转,删除最多三次旋转,在频繁的插入和删除场景中,红黑树的时间复杂度,是优于AVL树的。

hash() 方法

hash() 源码:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

hash 方法的主要作用是将 key 的 hashCode 值进行处理,得到最终的哈希值。由于 key 的 hashCode 值是不确定的,可能会出现哈希冲突,因此需要将哈希值通过一定的算法映射到 HashMap 的实际存储位置上。

hash 方法的原理是,先获取 key 对象的 hashCode 值,然后将 hashcode 值右移16位,让其高位与低位进行异或操作,得到一个新的哈希值。这就是扰动算法,目的是使 hash 值更加均匀。如果 key 是空,则默认哈希值为0。

然后 (n - 1) & hash (hash 值对数组长度-1进行与运算)得到索引。

为什么 HashMap 数组长度要取2的整数次方?

putVal()getNode() 的源码中有一个取模运算 (n - 1) & hash

  • 计算索引时效率更高:如果是2的 n 次幂可以使用与运算代替取模

  • 使哈希值均匀分布:当数组长度 n 取偶数时,n-1为奇数,最后一位必定为1,这样在进行与运算的时候,低位既可能为1,也可能为0。若 n 取奇数,n-1为偶数,最后一位为0,与运算的结果也必然是0,造成哈希值的不均匀分布。

put() 方法

put 方法是提供给用户使用的,底层是 putVal() 方法。

  1. 首先判断数组 table 是否为空或为 null,如果是则执行 resize() 进行扩容(初始化);
  2. 根据 key 计算 hash 值获得数组索引 i
  3. 如果 table[i] == null,说明这个位置没有元素,直接插入;
  4. 如果 table[i] != null,说明数组中这个位置有元素:
    • 判断这个元素的 key 是否和当前 key 一样,如果相同则直接覆盖 value;
    • 如果不一样则判断 table[i] 是否是红黑树,如果是,则直接在树中插入键值对;
    • 如果不是红黑树,遍历链表,在链表的尾部插入数据,然后判断链表长度是否大于8,如果大于8且数组长度大于64则把链表转化为红黑树,在遍历过程中如果发现当前 key 已存在则直接覆盖 value;
  5. 插入成功后,判断当前键值对数量是否超过了最大阈值 threshold(数组长度 * 0.75),如果超过,进行扩容。

HashMap 初始容量设置为多少合适?

HashMap 在初始化容量时,并不是设置多少容量实际就是多少容量,JDK 会默认帮我们计算一个相对合理的值,也就是第一个比用户传入值大的2的幂。(如:用户输入9,JDK 初始化为16,2的四次幂)

又因为当 HashMap 的元素个数超过阈值,也就是容量*负载因子0.75时,就会进行扩容,因此在传入初始容量时,可以设置为**expectedSize / 0.75 + 1**。

比如我们计划向 HashMap 中放入7个元素的时候,我们通过 expectedSize/0.75 + 1计算,7/0.75+1=10,10经过 JDK 处理之后,会被设置成16,这就大大的减少了扩容的几率。

为什么负载因子是0.75?

0.75在时间和空间成本上是更好的平衡,再高的话虽然会减小空间的开销,但增加了查找成本。

此外,由于 threshold = loadFactor * capacity,容量永远是2的n次幂,2的n次幂乘3/4永远是一个整数。

HashMap 的扩容机制

  1. 在添加元素或初始化的时候需要调用 resize() 进行扩容,第一次添加数据时初始化数组长度为16,设置扩容阈值为12,之后每次元素数量达到扩容阈值时再进行扩容。
  2. 每次扩容时,新数组的容量都是之前的2倍
  3. 扩容之后会创建一个新数组,需要把旧数组的数据移动到新数组中:
    • 没有 hash 冲突的节点,直接使用 e.hash & (newCap - 1) (哈希值和新数组长度减一进行与运算)计算节点在新数组的索引位置。

    • 如果是红黑树,走红黑树的添加。

    • 如果是链表,遍历链表,可能需要拆分链表。当 e.hash & OldCap 为0时,该元素停留在原始位置,否则移动到原始位置 + 增加的数组大小位置上。

HashMap1.7和1.8的区别

  • JDK1.7底层是数组 + 链表,JDK1.8底层是数组 + 链表 + 红黑树。

  • JDK1.7插入数据时使用的是头插法,有成环风险,JDK1.8是尾插法。

  • JDK1.7在解决哈希冲突时使用的是拉链法,而在 JDK1.8中,当链表长度大于8且数组长度大于64时,链表会转化为红黑树。扩容时,红黑树拆分成的树结点数小于等于临界值6个时,则退化为链表。

线程不安全

  • 在 hashMap1.7 中扩容的时候,因为采用的是头插法,所以会可能会有循环链表产生,导致数据有问题,在 1.8 版本已修复,改为了尾插法

  • 在任意版本的 hashMap 中,如果在插入数据时多个线程命中了同一个槽,可能会有数据覆盖的情况发生,导致线程不安全。

怎么解决?

  1. 给 hashMap 直接加锁,来保证线程安全。
  2. 使用 hashTable,比方法一效率高,其实就是在其方法上加了 synchronized 锁。
  3. 使用 concurrentHashMap,不管是其 1.7 还是 1.8 版本,本质都是减小了锁的粒度,减少线程竞争来保证高效。

ConcurrentHashMap

ConcurrentHashMap 是一种线程安全的高效 Map 集合。不支持键值为 null。

为什么键值不能为 null?

ConcurrentHashMap 的键值不能为 null 是因为无法分辨是 key 不存在还是有 key 值为 null,这在多线程里面是模糊不清的。

在 HashMap 中,因为它的设计就是给单线程用的,所以当我们 map.get(key) 返回 null 的时候,我们是可以通过 map.contains(key) 检查来进行检测的,如果它返回 true,则认为是存了一个 null,否则就是因为没找到而返回了 null。

但 ConcurrentHashMap 要用在并发场景中的,当我们 map.get(key) 返回 null 的时候,是没办法通过 map.contains(key) 检查来准确的检测,因为在检测过程中可能会被其他线程所修改,而导致检测结果并不可靠。

ConcurrentHashMap 如何保证线程安全?

JDK1.7中,ConcurrentHashMap 使用了分段锁技术,也就是将哈希表分成多个段,每个段都继承了 ReentrantLock,每个段都拥有一个独立的锁。

在多个线程同时访问哈希表时,只需要锁住需要操作的那个段,此时其他段也是可以操作的,提高了并发性能。

虽然分段锁提高了并发性,但由于 JDK1.7中 ConcurrentHashMap 的段数是固定的,并发很高的时候仍可能导致热点段,从而成为性能瓶颈。另外每个段都是独立的结构,可能会导致较高的内存占用

所以在 JDK1.8 中进行了优化,采用基于节点锁的方法,并在内部大量使用了 CAS 操作,减少了锁竞争问题。

JDK 1.8中,ConcurrentHashMap 的数据结构和 JDK1.8的 HashMap 一样,都是数组 + 链表 + 红黑树,采用 CAS + synchronized 保证线程安全。

当 ConcurrentHashMap 添加元素时,如果某个节点为空,会通过 CAS 操作添加新节点;如果节点不为空,使用 synchronized 锁住当前节点,再次尝试 put。

这样可以避免分段锁机制下锁粒度太大,以及在高并发场景下由于线程数量过多导致的锁竞争问题,提高并发性能。

JDK1.8的 ConcurrentHashMap 相比于 JDK1.7而言:

  • 锁粒度更细:通过锁定单个节点而不是锁定整个段,大幅降低了锁的竞争;

  • CAS 操作:大量使用无锁的 CAS 操作,提高效率;

  • 提高性能:通过减少锁的数量和竞争,在高并发环境下有更好的性能;

  • 内存效率:通过减少锁的数量和使用更简洁的数据结构,提高了内存效率。

JDK1.8 中为什么不用 ReentrantLock 而用 synchronized?

  1. JDK1.8中的 ConcurrentHashMap 是针对节点加锁的,而不是JDK 1.7中的基于段,所以并发冲突小很多,因此 synchronized 不会频繁升级成重量级锁,大部分情况下都是偏向锁和轻量级锁,性能上和 ReentrantLock差不多。

  2. synchronized 相比于 ReentrantLock,不需要手动加锁和释放锁

  3. synchronized 是 Java 内置的关键字,JVM 在运行时能做出相应的优化措施,比如锁粗化、锁消除等。

  4. 当获取锁失败时,ReentrantLock 会导致线程挂起,synchronized 会通过自旋避免线程被挂起,从而减少线程上下文切换的开销

  5. synchronized 是利用对象头的一部分标记实现的锁,ReentrantLock 是一个独立的对象,每次使用都需要实例化一个 ReentrantLock 对象内存开销大

JDK1.7版本

JDK1.7底层采用分段数组 + 链表实现:

ConcurrentHashMap 是由 Segment 数组和 HashEntry 数组构成的。ConcurrentHashMap 里包含一个 Segment 数组,每个 Segment 都可以看做是一个 HashMap,是一种数组 + 链表结构。

Segment 本身继承了 ReentrantLock,Segment 本身就是一个可重入的锁。当 put 的时候,当前 Segment 会将自己锁住,此时其他线程无法操作这个 Segment, 但不会影响到其他 Segment 的操作。

JDK1.7的 ConcurrentHashMap 将数据分段,对每一段数据分配一把锁,这样既保证了线程安全,当一个线程占用该段数据的锁时,其他段的数据也能被其他线程访问

put 流程

  1. 计算 hash 值,定位到 segment,segment 如果是空就先初始化。

  2. 使用 ReentrantLock 加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功。

  3. 遍历 HashEntry,就是和 HashMap ⼀样,数组中 key 一样就直接替换,不存在就再插入链表,链表同样。

get 流程

key 通过 hash 值定位到 segment,再遍历链表定位到具体的元素上,需要注意的是 value 是 volatile 的,所以 get 是不需要加锁的。

JDK1.8版本

JDK1.8采用的数据结构和 HashMap 1.8一样,都是数组 + 链表 + 红黑树

JDK1.8的 ConcurrentHashMap 采用 CAS 添加新节点,采用 synchronized 锁定链表或红黑树的首节点

put 流程

  1. 首先计算 hash 值,遍历 Node 数组,如果 Node 是空的话,就通过 CAS + 自旋的方式初始化。
  2. 如果当前数组位置是空,则直接通过 CAS 写入数据。
  3. 如果 hash == MOVED,说明需要扩容,进行扩容。
  4. 如果都不满足,就使用 synchronized 写入数据,写入数据同样判断链表、红黑树,和 HashMap 一样,写入前判断 key 是否相同,相同则覆盖,否则插入链表或红黑树。当链表长度超过8时转换成红黑树。

get 流程

通过 key 计算 hash 值,如果 key 相同就返回,如果是红黑树按照红黑树获取,否则就遍历链表获取。