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() 方法。
- 首先判断数组 table 是否为空或为 null,如果是则执行
resize()进行扩容(初始化); - 根据 key 计算 hash 值获得数组索引 i;
- 如果 table[i] == null,说明这个位置没有元素,直接插入;
- 如果 table[i] != null,说明数组中这个位置有元素:
- 判断这个元素的 key 是否和当前 key 一样,如果相同则直接覆盖 value;
- 如果不一样则判断 table[i] 是否是红黑树,如果是,则直接在树中插入键值对;
- 如果不是红黑树,遍历链表,在链表的尾部插入数据,然后判断链表长度是否大于8,如果大于8且数组长度大于64则把链表转化为红黑树,在遍历过程中如果发现当前 key 已存在则直接覆盖 value;
- 插入成功后,判断当前键值对数量是否超过了最大阈值 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 的扩容机制
- 在添加元素或初始化的时候需要调用
resize()进行扩容,第一次添加数据时初始化数组长度为16,设置扩容阈值为12,之后每次元素数量达到扩容阈值时再进行扩容。 - 每次扩容时,新数组的容量都是之前的2倍。
- 扩容之后会创建一个新数组,需要把旧数组的数据移动到新数组中:
-
没有 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 中,如果在插入数据时多个线程命中了同一个槽,可能会有数据覆盖的情况发生,导致线程不安全。
怎么解决?
- 给 hashMap 直接加锁,来保证线程安全。
- 使用 hashTable,比方法一效率高,其实就是在其方法上加了 synchronized 锁。
- 使用 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?
-
JDK1.8中的 ConcurrentHashMap 是针对节点加锁的,而不是JDK 1.7中的基于段,所以并发冲突小很多,因此 synchronized 不会频繁升级成重量级锁,大部分情况下都是偏向锁和轻量级锁,性能上和 ReentrantLock差不多。
-
synchronized 相比于 ReentrantLock,不需要手动加锁和释放锁。
-
synchronized 是 Java 内置的关键字,JVM 在运行时能做出相应的优化措施,比如锁粗化、锁消除等。
-
当获取锁失败时,ReentrantLock 会导致线程挂起,synchronized 会通过自旋避免线程被挂起,从而减少线程上下文切换的开销。
-
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 流程
-
计算 hash 值,定位到 segment,segment 如果是空就先初始化。
-
使用 ReentrantLock 加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功。
-
遍历 HashEntry,就是和 HashMap ⼀样,数组中 key 一样就直接替换,不存在就再插入链表,链表同样。
get 流程
key 通过 hash 值定位到 segment,再遍历链表定位到具体的元素上,需要注意的是 value 是 volatile 的,所以 get 是不需要加锁的。
JDK1.8版本
JDK1.8采用的数据结构和 HashMap 1.8一样,都是数组 + 链表 + 红黑树。
JDK1.8的 ConcurrentHashMap 采用 CAS 添加新节点,采用 synchronized 锁定链表或红黑树的首节点。
put 流程
- 首先计算 hash 值,遍历 Node 数组,如果 Node 是空的话,就通过 CAS + 自旋的方式初始化。
- 如果当前数组位置是空,则直接通过 CAS 写入数据。
- 如果 hash == MOVED,说明需要扩容,进行扩容。
- 如果都不满足,就使用 synchronized 写入数据,写入数据同样判断链表、红黑树,和 HashMap 一样,写入前判断 key 是否相同,相同则覆盖,否则插入链表或红黑树。当链表长度超过8时转换成红黑树。
get 流程
通过 key 计算 hash 值,如果 key 相同就返回,如果是红黑树按照红黑树获取,否则就遍历链表获取。