@[toc]


深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图1


1. 引入

Java中Collection是所有单列集合的最顶层接口,它的子接口包括List、Set、Queue,接口中定义了所有单列集合实现时应遵循的规范。Map接口是所有双列集合的顶层接口,它的继承树如上所示,它具有如下的特点:

  • Map集合中的元素,key和value的数据类型可以相同也可以不同
  • Map集合中的元素,key不允许重复,value可以重复
  • Map集合中的元素,key和value是一一对应的

关于Collection接口和Map接口,及其它们各自实现类的使用请参阅以下两篇博文,这里不再赘述。

浅析Java中的Collection接口

浅析Java中的Map接口及实现类

从文章的标题出发,我们下面着重关于与Map接口及其它的实现类底层的实现原理,并从源码的角度详细的进行剖析。



2. Map接口

从上面的叙述可知,Map中存放的是key-value类型的数据,key和value是一一对应的,即通过指定的key总能找到对应value。其中key用Set来存放,从而保证key的不重复,即同一个Map对象所对应的的类须重写hashCode()equals()。通常使用不可变类型的值作为key,如常用的Integer和String。Map整体存放数据的原理如下所示:

深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图2

其中Entry是定义在Map接口内部的一个接口,不同Map的实现类都需要实现Entry接口。当使用Map.put()往集合中存放key-value的数据时,实际上是存放到了一个一个的Entry中。因此,当需要遍历Map时,可以先获取到Entryset,然后使用Entry.getkey()Entry.getValue()来进行键和值的获取。Entry接口定义如下:

  1. interface Entry<K,V> {
  2. // 获取键
  3. K getKey();
  4. // 获取值
  5. V getValue();
  6. // 设置值
  7. V setValue(V value);
  8. boolean equals(Object o);
  9. int hashCode();
  10. // 一系列的定制的Comparator,供排序时使用
  11. public static <K extends Comparable<? super K>, V> Comparator<Map.Entry<K,V>> comparingByKey() {
  12. return (Comparator<Map.Entry<K, V>> & Serializable)
  13. (c1, c2) -> c1.getKey().compareTo(c2.getKey());
  14. }
  15. public static <K, V extends Comparable<? super V>> Comparator<Map.Entry<K,V>> comparingByValue() {
  16. return (Comparator<Map.Entry<K, V>> & Serializable)
  17. (c1, c2) -> c1.getValue().compareTo(c2.getValue());
  18. }
  19. public static <K, V> Comparator<Map.Entry<K, V>> comparingByKey(Comparator<? super K> cmp) {
  20. Objects.requireNonNull(cmp);
  21. return (Comparator<Map.Entry<K, V>> & Serializable)
  22. (c1, c2) -> cmp.compare(c1.getKey(), c2.getKey());
  23. }
  24. public static <K, V> Comparator<Map.Entry<K, V>> comparingByValue(Comparator<? super V> cmp) {
  25. Objects.requireNonNull(cmp);
  26. return (Comparator<Map.Entry<K, V>> & Serializable)
  27. (c1, c2) -> cmp.compare(c1.getValue(), c2.getValue());
  28. }
  29. }

3. 背景知识

在基本了解了Map接口底层的实现基础上,接下来分别来看一下Map接口的实现类,如HashMapLinkedHashMapTreeMap底层的实现原理,不过在阅读它们的源码之前,我们需要回顾一些背景知识,从而来方便后续源码的理解。


3.1 数据存储

计算机中常用于数据存储的数据结构有数组链表,不管是各种类型的树还是更为复杂的图,它们底层的实现都是依赖于数组或是链表。例如,图有邻接矩阵和邻接表两种实现方式,它们各自的实现依赖就是数组和链表。数组使用的是一段物理上连续的空间来进行数据的存放,因此可以根据指定的索引进行数据的存取,适合于频繁进行查询的场景。但是物理上连续空间的要求,使得数据的插入和删除需要移动数组元素,因此并不适用于频繁进行数据插入和删除的操作。

链表并不要求使用物理上连续的空间,而是通过节点之间的引用来进行数据间的联系。因此,当需要进行数据的插入和删除时,只需要处理节点间的引用即可,适用于频繁进行插入和删除的场景。但是,链表并不能像数组一样通过索引来直接进行数据访问,而是每次访问都需要遍历一遍链表,时间复杂度相比数组较高。

深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图3

基于数组和链表实现的诸如二叉树和图等其他的数据结构,由于各自结构上的特点,分别适合于不同的应用场景,这些不是这里关注的重点。之所以要讲到数组和链表,原因是Map的实现中需要用到它们。


3.2 哈希算法

哈希算法将任意长度的二进制值映射为较短的固定长度的二进制值,这个小的二进制值称为哈希值。哈希值是一段数据唯一且极其紧凑的数值表示形式。如果散列一段明文而且哪怕只更改该段落的一个字母,随后的哈希都将产生不同的值。要找到散列为同一个值的两个不同的输入,在计算上是不可能的,所以数据的哈希值可以检验数据的完整性,一般用于快速查找和加密算法。

哈希算法

简单来说,任意长度的数据经过哈希算法都可以得到一段固定长度的输出,这里的输出称为哈希值。而且对于一段具体的数据来说,如果哈希算法不变,那么它对应的哈希值就是唯一的,当然相同哈希值可能会对应不同的数据。

常用的哈希算法有:

算法 输入长度(位) 输入长度(字节)
MD5 128 16
SHA-1 160 20
RipeMD-160 160 20
SHA-256 256 32
SHA-512 512 64

3.3 哈希表

哈希表(散列表)就是哈希算法借助数组的一种具体实现,不管是查询操作,还是数据的插入和删除操作,哈希表都可以在O(1)时间复杂度内完成。哈希表的映射功能的实现还依赖于具体的哈希函数深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图4,它的形式如下所示:

深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图5%0A#card=math&code=%5Ctext%7B%E5%AD%98%E6%94%BE%E4%BD%8D%E7%BD%AE%7D%20%3D%20f%28%E6%95%B0%E6%8D%AE%29%0A)

哈希函数的实现方式多种多样,原则上只要能满足哈希算法的功能要求都可以作为哈希函数,例如常用的取模等。一个优秀的哈希函数应该是计算上简单的,散列地址上分配是均匀的。前面讲到,对于一段具体的数据来说,只要哈希函数是固定的,那么它对应的哈希值就是唯一的。但是相同哈希值可能会对应不同的数据,这就是哈希冲突。即时再好的哈希函数,仍然无法彻底避免哈希冲突的出现。故而需要相应的方法来处理它。常用于解决哈希冲突的方法有:

  • 开放定址法:当关键字key的哈希地址深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图6出现冲突时,以深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图7为基础,产生另一个哈希地址深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图8,如果深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图9仍然冲突,再以深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图10为基础产生另一个哈希地址深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图11,…,直到找出一个不冲突的哈希地址深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图12,将相应元素存入其中 深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图13%20%2Bd_i)%20%25%20m%0A#card=math&code=H_i%20%3D%20%28H%28Key%29%20%2Bd_i%29%20%25%20m%0A)


其中H表示哈希函数,m为表长,深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图14为增长序列

  • 再哈希法:同时构造不同的哈希函数,当某个哈希函数发生哈希冲突时,再使用另一个哈希函数计算,直到不再发生冲突为止

  • 链地址法:将所有哈希地址相同的元素构成一个单链表,并将单链表的头指针存在哈希表中,适用于经常进行插入和删除的场景



4. HashMap实现原理

HashMap是Map接口最为常用的实现类,它的底层实现就是哈希表。针对于不同的JDK版本来说,又具有不同的实现方式:

  • JDK7及之前:数组 + 链表
  • JDK8及之后:数组 + 链表 + 红黑树

由于不同版本JDK中HashMap的实现有些差别,因此下面分别就JDK7和JDK8中的源码实现为例,对HahsMap的底层源码实现做一个分析。对于源码的解读来说,分别从属性字段、构造函数和常用方法三个方向入手。


4.1 JDK7

JDK 7中HashMap的定义如下所示:

  1. public class HashMap<K,V>
  2. extends AbstractMap<K,V>
  3. implements Map<K,V>, Cloneable, Serializable{}

它不仅继承了Map接口,同时还继承了Cloneable和Serializable两个接口,便于对象的克隆及序列化和反序列化操作。


4.1.1 字段

首先来看一下HashMap的字段定义:

  1. static final int DEFAULT_INITIAL_CAPACITY = 16; // 默认初始容量大小为16
  2. static final int MAXIMUM_CAPACITY = 1 << 30; // 最大可设置容量
  3. static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认加载因子为0.75,用于扩容
  4. transient Entry<K,V>[] table; // transient表示该字段不进行序列化,注意这里定义的为table,和JDK8中是不同的
  5. transient int size;
  6. int threshold; // 扩容临界值
  7. final float loadFactor;
  8. transient int modCount;
  9. static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
  10. transient boolean useAltHashing;
  11. transient final int hashSeed = sun.misc.Hashing.randomHashSeed(this);

其中:

  • DEFAULT_INITIAL_CAPACITY:定义了HashMap的默认容量为16,即初始时哈希表中数组的长度
  • MAXIMUM_CAPACITY:HashMap的最大容量
  • DEFAULT_LOAD_FACTOR:默认的加载因子为0.75,它和HashMap的容量决定了何时进行扩容
  • threshold:扩容的临界值,计算公式为深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图15

4.1.2 构造函数

然后看一下HashMap的构造函数:

  1. // 1. 无参构造,使用默认的容量大小和加载因子
  2. public HashMap() {
  3. this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
  4. }
  5. // 2. 只指定初始容量
  6. public HashMap(int initialCapacity) {
  7. this(initialCapacity, DEFAULT_LOAD_FACTOR);
  8. }
  9. // 3. 指定初始容量和加载因子
  10. public HashMap(int initialCapacity, float loadFactor) {
  11. // 初始容量不允许为负数值,否则抛参数非法异常
  12. if (initialCapacity < 0)
  13. throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
  14. // 当初始容量超过了最大容量,则只能初始化为规定的最大容量
  15. if (initialCapacity > MAXIMUM_CAPACITY)
  16. initialCapacity = MAXIMUM_CAPACITY;
  17. // 加载因子不允许为负数值和null,否则抛参数非法异常
  18. if (loadFactor <= 0 || Float.isNaN(loadFactor))
  19. throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
  20. // 注意:初始容量最终只能是2的次幂,便于后续的扩容
  21. int capacity = 1;
  22. while (capacity < initialCapacity)
  23. capacity <<= 1;
  24. // 初始化加载因子
  25. this.loadFactor = loadFactor;
  26. // 初始化扩容阈值
  27. threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
  28. // 新建一个Entry类型的table,用于存放数据,大小就是前面得到的capacity
  29. table = new Entry[capacity];
  30. useAltHashing = sun.misc.VM.isBooted() &&
  31. (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
  32. // 最后的初始化操作
  33. init();
  34. }
  35. // 4. 使用已有的Map对象进行初始化
  36. public HashMap(Map<? extends K, ? extends V> m) {
  37. this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
  38. DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
  39. putAllForCreate(m);
  40. }

HashMap中提供了四种构造函数的实现方式,例如,最为常用的是直接调用无参构造来进行对象的实例化,此时初始容量和加载因子使用的就是字段中定义的默认值:

  1. Map<String, String> map = new HashMap<>();

当然也可以调用HashMap(int initialCapacity)来创建对象,此时初始容量就是在函数中传入的值,最终内部调用全参的构造函数,详细的分析可见上面的源码注释。

至于最后一种构造函数,参数列表中传递的是一个已有的Map对象,首先计算得到初始容量和加载因子,再调用全参的构造函数进行字段的初始化,最后调用putAllForCreate()来进行对象的初始化。它的源码实现如下:

  1. private void putAllForCreate(Map<? extends K, ? extends V> m) {
  2. for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
  3. putForCreate(e.getKey(), e.getValue());
  4. }

遍历map中的每一个Entry,获取到对应的key和value后,又调用了putForCreate(),如下所示:

  1. private void putForCreate(K key, V value) {
  2. // 如果此时key为null,则对应的哈希值为0,放到数组首部;否则计算对应的哈希值
  3. int hash = null == key ? 0 : hash(key);
  4. // 获取在数组中的存放位置
  5. int i = indexFor(hash, table.length);
  6. // 遍历此时table中的元素
  7. for (Entry<K,V> e = table[i]; e != null; e = e.next) {
  8. Object k;
  9. // 如果两个map相等,直接返回
  10. if (e.hash == hash &&
  11. ((k = e.key) == key || (key != null && key.equals(k)))) {
  12. e.value = value;
  13. return;
  14. }
  15. }
  16. // 否则初始化Entry对象
  17. createEntry(hash, key, value, i);
  18. }

最终调用的是createEntry()来初始化每一个Entry对象。

  1. void createEntry(int hash, K key, V value, int bucketIndex) {
  2. Entry<K,V> e = table[bucketIndex];
  3. table[bucketIndex] = new Entry<>(hash, key, value, e);
  4. size++;
  5. }

4.1.3 常用方法

由上面的分析可知,HashMap的底层实现使用的是数组 + 链表形式的哈希表,而且比较重要有初始容量、最大容量和加载因子等参数。HashMap整体的数据存放如下所示:

深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图16

那么它们在提供的方法中是如何被使用的呢?


4.1.3.1 put

首先来看一下最常用的put(),方法的实现流程大致如下:

  • 计算要添加的Entry对象中key的哈希值,经过一系列后续的计算,得到它在table中的索引位置
  • 如果该位置没有其他Entry存放,则直接添加到该位置
  • 如果当前索引处已有其他的Entry存在,那么依次遍历该位置的Entry链中的每个Entry,进行比较:

    • 如果彼此哈希值不同,那么成功添加
    • 如果彼此哈希值相同,那么继续使用equals()比较,如果为true,则使用当前的value进行覆盖;如果遍历结束都是false,则成功添加,添加的Entry指向原有的Entry元素

这里新添加的Entry作为该位置Entry链的head存在。方法的源码如下所示:

  1. public V put(K key, V value) {
  2. // 如果此时key为null, 那么调用putForNullKey方法将其放到table中
  3. if (key == null)
  4. return putForNullKey(value);
  5. // 如果key不为null,首先计算对应的哈希值
  6. int hash = hash(key);
  7. // 通过计算的哈希值找到table中对应的存放位置
  8. int i = indexFor(hash, table.length);
  9. // 得到了table对应的位置后,遍历该位置的链表
  10. for (Entry<K,V> e = table[i]; e != null; e = e.next) {
  11. Object k;
  12. // 依次比较当前元素和链表中元素是否相等
  13. // 如果找到存在value相等的元素,则将当前元素的value覆盖掉原有的value
  14. if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
  15. V oldValue = e.value;
  16. e.value = value;
  17. e.recordAccess(this);
  18. // 并返回旧的value
  19. return oldValue;
  20. }
  21. }
  22. // 如果当前位置没有其他元素存放,则调用addEntry将其直接存放到该位置
  23. modCount++;
  24. addEntry(hash, key, value, i);
  25. return null;
  26. }

其中putForNullKey()的源码为:

  1. private V putForNullKey(V value) {
  2. // 遍历table寻找key=null的位置,如果有,则使用传入的value替换掉旧的value
  3. for (Entry<K,V> e = table[0]; e != null; e = e.next) {
  4. if (e.key == null) {
  5. V oldValue = e.value;
  6. e.value = value;
  7. e.recordAccess(this);
  8. // 返回旧的value
  9. return oldValue;
  10. }
  11. }
  12. modCount++;
  13. addEntry(0, null, value, 0);
  14. return null;
  15. }

它的实现也是判当前table索引为0的位置有没有元素存放,如果有,则使用传入的value覆盖掉已有的value,否则调用addEntry()直接存到0位置。addEntry()的源码实现为:

  1. void addEntry(int hash, K key, V value, int bucketIndex) {
  2. // 首先判断是否需要扩容
  3. // 如果需要满足了扩容的条件
  4. if ((size >= threshold) && (null != table[bucketIndex])) {
  5. // 先扩容
  6. resize(2 * table.length);
  7. // 计算扩容后对应的哈希值
  8. hash = (null != key) ? hash(key) : 0;
  9. // 获取到应存放的索引位置
  10. bucketIndex = indexFor(hash, table.length);
  11. }
  12. // 调用createEntry实现最终的保存
  13. createEntry(hash, key, value, bucketIndex);
  14. }

它最终调用的是createEntry()将元素存放到对应位置的Entry中,实现如下:

  1. void createEntry(int hash, K key, V value, int bucketIndex) {
  2. // 先取bucketIndex位置的元素
  3. Entry<K,V> e = table[bucketIndex];
  4. // 将传入的元素放到bucketIndex位置
  5. table[bucketIndex] = new Entry<>(hash, key, value, e);
  6. // 元素个数加1
  7. size++;
  8. }

此外,哈希值的计算使用的是hash(),它的实现如下所示:

  1. final int hash(Object k) {
  2. int h = 0;
  3. if (useAltHashing) {
  4. // 如果此时的key为String类型,直接使用stringHash32获取到哈希值
  5. if (k instanceof String) {
  6. return sun.misc.Hashing.stringHash32((String) k);
  7. }
  8. // 否则先设定一个哈希种子
  9. h = hashSeed;
  10. }
  11. // 先使用hashCode方法获取到哈希值后,再和种子做异或操作
  12. h ^= k.hashCode();
  13. // 然后再和本身移位后的结果异或
  14. h ^= (h >>> 20) ^ (h >>> 12);
  15. // 返回最终使用的哈希值
  16. return h ^ (h >>> 7) ^ (h >>> 4);
  17. }

4.1.3.2 get

get()就是使用指定的key来从Map中获取到相应的value,它的源码实现如下:

  1. public V get(Object key) {
  2. // 如果输入的key为null,ze调用getForNullKey返回table索引为0位置的value
  3. if (key == null)
  4. return getForNullKey();
  5. // 否则,调用getEntry获取到相应的value
  6. Entry<K,V> entry = getEntry(key);
  7. // 如果获取到的entry为null,则返回null;否则返回相应的value
  8. return null == entry ? null : entry.getValue();
  9. }

其中getForNullKey()的实现如下:

  1. private V getForNullKey() {
  2. // 遍历table找到第一个存放key为null的值
  3. for (Entry<K,V> e = table[0]; e != null; e = e.next) {
  4. // 如果之前已经存放过key为null的Entry,则返回对应的value
  5. if (e.key == null)
  6. return e.value;
  7. }
  8. // 如果table中没有针对key=null的值存在,直接返回null
  9. return null;
  10. }

这就是HashMap和Hashtable的一个不同之处:

  • Hashtable不允许使用null作为key和value
  • HashMap允许使用null作为key和value

4.1.3.3 resize

resize()对应的功能就是扩容,那为什么需要扩容?以及HashMap中是如何实现扩容的呢?由上面的分析知道,一个优秀的哈希函数不仅计算要简单,更重要的是散列地址要均匀。哈希表中存放元素时,大部分的元素都可以直接存放到数组中,只有一小部分需构成链,这样在数据的有关操作时,只需要针对于数组即可,此时的效率是很高的。

HashMap在初始化时通过capacity指定了table的初始容量,当存放的元素不太多时,哈希冲突的概率很小,但是当存放的元素越来越多时,哈希冲突就会频繁发生。为了存放所有的元素,table的每个位置可能都会维持一条长长的链表,而对链表执行查询操作,效率是很低的。既然在初始容量下,当前的哈希函数已经无法保持它的优异性,那么就对table进行扩容。扩容后原来存放的元素要重新计算在新table中的位置,然后再放到相应的位置上,这里性能开销也是很大。但如果频繁的执行查询操作,相比于对链表不停地查询,这样的开销还是可以忍受的。

那么HashMap是怎么执行扩容操作的呢? 当HashMap中元素的个数超过了数组大小和加载因子的乘积时,就会对table进行扩容。例如HashMap初始默认的数组大小为16,加载因子为0.75,那么当table中元素个数大于12(深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图17) 时就会扩容,此时扩容操作会将table的大小扩展为当前的2倍,如深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图18,然后重新计算元素在新table中的位置,最后将它们重新放到新table中。

具体表现在使用addEntry()最终添加元素时,如果满足了给定的条件,就需要进行扩容。

  1. void addEntry(int hash, K key, V value, int bucketIndex) {
  2. if ((size >= threshold) && (null != table[bucketIndex])) {
  3. // 扩容
  4. resize(2 * table.length);
  5. // 如果key不为null要重新计算哈希值,否则哈希值设为0
  6. hash = (null != key) ? hash(key) : 0;
  7. // 根据哈希值和新table的大小重新计算在table中的位置索引
  8. bucketIndex = indexFor(hash, table.length);
  9. }
  10. // 最后按照新的位置存放元素
  11. createEntry(hash, key, value, bucketIndex);
  12. }

resize()具体的源码实现如下:

  1. // 参数列表中指定新的容量
  2. void resize(int newCapacity) {
  3. // 首先保存旧table
  4. Entry[] oldTable = table;
  5. // 获取旧table的容量
  6. int oldCapacity = oldTable.length;
  7. // 如果旧table的容量已经到达了HashMap的最大容量,即2^30
  8. if (oldCapacity == MAXIMUM_CAPACITY) {
  9. // 修改阈值为Integer的最大值(2^31-1),这样以后就不会扩容了
  10. threshold = Integer.MAX_VALUE;
  11. return;
  12. }
  13. // 如果table的容量还没有到达最大容量值
  14. // 首先使用新容量构建一个Entry数组,即新的table
  15. Entry[] newTable = new Entry[newCapacity];
  16. boolean oldAltHashing = useAltHashing;
  17. useAltHashing |= sun.misc.VM.isBooted() &&
  18. (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
  19. // 判断是否需要重新进行哈希值计算
  20. boolean rehash = oldAltHashing ^ useAltHashing;
  21. // 将旧table中的元素重新放到新table对应的位置上
  22. transfer(newTable, rehash);
  23. // 替换到原有的table
  24. table = newTable;
  25. // 根据新table的大小重新计算threshold
  26. threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
  27. }

有关数组重复添加操作的方法实现transfer()源码如下所示:

  1. void transfer(Entry[] newTable, boolean rehash) {
  2. // 获取新table的大小
  3. int newCapacity = newTable.length;
  4. // 遍历旧table中的每一个Entry
  5. for (Entry<K,V> e : table) {
  6. // 如果当前的Entry不为null
  7. while(null != e) {
  8. // 获取链上的Entry
  9. Entry<K,V> next = e.next;
  10. // 如果此时需要重新计算hash,则获取新的哈希值
  11. if (rehash) {
  12. e.hash = null == e.key ? 0 : hash(e.key);
  13. }
  14. // 重新计算每个元素在数组中的位置
  15. int i = indexFor(e.hash, newCapacity);
  16. // 标记
  17. e.next = newTable[i];
  18. // 将元素放在数组上
  19. newTable[i] = e;
  20. // 访问下一个Entry链上的元素
  21. e = next;
  22. }
  23. }
  24. }

其中e.next = newTable[i]中newTable[i]的引用赋给了e.next,也就是使用了单链表的头插法,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话)。


4.2 JDK8


4.2.1 字段

HashMap在JDK8中是如何实现的呢?它和JDK7中的实现有何不同呢?下面我们依然通过字段、构造函数和常用方法来看一下区别之处。

  1. public class HashMap<K,V> extends AbstractMap<K,V>
  2. implements Map<K,V>, Cloneable, Serializable {
  3. // 指定了一个固定的序列化ID
  4. private static final long serialVersionUID = 362498820763181265L;
  5. static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // a 16
  6. static final int MAXIMUM_CAPACITY = 1 << 30;
  7. static final float DEFAULT_LOAD_FACTOR = 0.75f;
  8. static final int TREEIFY_THRESHOLD = 8; // 链表转为红黑树的阈值
  9. static final int UNTREEIFY_THRESHOLD = 6; // 红黑树重新转换为链表的阈值
  10. static final int MIN_TREEIFY_CAPACITY = 64;
  11. transient Node<K,V>[] table; // ! 这里的table是Node类型,而不是JDK中的Entry类型
  12. transient Set<Map.Entry<K,V>> entrySet;
  13. transient int size;
  14. transient int modCount;
  15. int threshold;
  16. final float loadFactor;
  17. }

除了和JDK7中定义的相同字段外,还有一些新的字段加入,如:

  • serialVersionUID:序列化ID,作为唯一识别标志,用于序列化和反序列化

  • TREEIFY_THRESHOLD:table的Bucket中链表的长度大于该值时,将链表转换为红黑树,默认为8

  • UNTREEIFY_THRESHOLD:Bucket中红黑树存储的元素个数小于该值时,转换为链表,默认为6

  • MIN_TREEIFY_CAPACITY:Bucket中元素数被转换为红黑树的最小容量,默认为64

  • table:此时的table的类型为Node,它是HashMap中定义的一个静态的内部类,Node的定义如下:

    1. static class Node<K,V> implements Map.Entry<K,V> {
    2. final int hash;
    3. final K key;
    4. V value;
    5. Node<K,V> next;
    6. Node(int hash, K key, V value, Node<K,V> next) {
    7. this.hash = hash;
    8. this.key = key;
    9. this.value = value;
    10. this.next = next;
    11. }
    12. public final K getKey() { return key; }
    13. public final V getValue() { return value; }
    14. public final String toString() { return key + "=" + value; }
    15. public final int hashCode() {
    16. return Objects.hashCode(key) ^ Objects.hashCode(value);
    17. }
    18. public final V setValue(V newValue) {
    19. V oldValue = value;
    20. value = newValue;
    21. return oldValue;
    22. }
    23. public final boolean equals(Object o) {
    24. if (o == this)
    25. return true;
    26. if (o instanceof Map.Entry) {
    27. Map.Entry<?,?> e = (Map.Entry<?,?>)o;
    28. if (Objects.equals(key, e.getKey()) &&
    29. Objects.equals(value, e.getValue()))
    30. return true;
    31. }
    32. return false;
    33. }
    34. }

4.2.2 构造函数

接着看一下构造函数中如何操作上述定义的字段,同样提供了四个构造函数,基本上和JDK7中是一致的。

  1. public HashMap(int initialCapacity, float loadFactor) {
  2. if (initialCapacity < 0)
  3. throw new IllegalArgumentException("Illegal initial capacity: " +
  4. initialCapacity);
  5. if (initialCapacity > MAXIMUM_CAPACITY)
  6. initialCapacity = MAXIMUM_CAPACITY;
  7. if (loadFactor <= 0 || Float.isNaN(loadFactor))
  8. throw new IllegalArgumentException("Illegal load factor: " +
  9. loadFactor);
  10. // 这里并没有直接按照默认初始容量初始化table
  11. this.loadFactor = loadFactor;
  12. this.threshold = tableSizeFor(initialCapacity);
  13. }
  14. public HashMap(int initialCapacity) {
  15. this(initialCapacity, DEFAULT_LOAD_FACTOR);
  16. }
  17. public HashMap() {
  18. this.loadFactor = DEFAULT_LOAD_FACTOR;
  19. }
  20. public HashMap(Map<? extends K, ? extends V> m) {
  21. this.loadFactor = DEFAULT_LOAD_FACTOR;
  22. putMapEntries(m, false);
  23. }

但不同之处在于,这里并没有在全参构造函数中直接初始化一个大小为capacity的table,只是设置了loadFactor和threshold。另外HashMap(Map<? extends K, ? extends V> m)中的实现也有差别,这些等看完常用方法中的改变后再回头来理解。


4.2.3 常用方法

4.2.3.1 put

方法执行流程如下:
深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图19

下面依然看一下put()get()resize()这三个方面的具体实现,首先是put()的实现,源码如下所示:

  1. public V put(K key, V value) {
  2. return putVal(hash(key), key, value, false, true);
  3. }

这里在方法体内部并没有具体的实现逻辑,而是调用了putVal(),它的源码实现如下:

  1. final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
  2. boolean evict) {
  3. // 常量定义
  4. Node<K,V>[] tab; Node<K,V> p; int n, i;
  5. // 如果当前的table为null,调用resize()初始化table的大小,创建一个哈希表
  6. // 由于最一开始初始化时并没有初始化table,所以第一次调用put方法时table必定为null
  7. if ((tab = table) == null || (n = tab.length) == 0)
  8. n = (tab = resize()).length;
  9. // 如果当前Bucket没有哈希冲突,则直接把key和value构造为Node类型后插入
  10. if ((p = tab[i = (n - 1) & hash]) == null)
  11. tab[i] = newNode(hash, key, value, null);
  12. // 如果有哈希冲突
  13. else {
  14. Node<K,V> e; K k;
  15. // 比较头节点的扰动hash值及key值
  16. // 如果相同则说明存入的节点key已存在,而且就是头节点
  17. // 先获取该节点,是否覆盖其值进一步处理
  18. if (p.hash == hash &&
  19. ((k = p.key) == key || (key != null && key.equals(k))))
  20. e = p;
  21. // 如果是采用红黑树的方式处理冲突,则通过红黑树的putTreeVal方法去插入这个键值对
  22. else if (p instanceof TreeNode)
  23. e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
  24. else {
  25. // 否则说明此时采用的仍是链表结构
  26. // 遍历当前的链表,判断链表中是否存在和要插入的数据中key相同的节点
  27. for (int binCount = 0; ; ++binCount) {
  28. // 如果遍历到尾部仍未发现,说明要插入的数据可作为一个新节点
  29. if ((e = p.next) == null) {
  30. // 构造为Node类型作为当前链的最后一个数据插入
  31. // 这里采用的是尾插法
  32. p.next = newNode(hash, key, value, null);
  33. // 如果此时链的长度大于TREEIFY_THRESHOLD这个临界值,则把链变为红黑树
  34. if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
  35. treeifyBin(tab, hash);
  36. break;
  37. }
  38. // 如果存在相等的节点
  39. if (e.hash == hash &&
  40. ((k = e.key) == key || (key != null && key.equals(k))))
  41. break;
  42. p = e;
  43. }
  44. }
  45. // 把相等的键的值替换为新值
  46. if (e != null) { // existing mapping for key
  47. V oldValue = e.value;
  48. // 方法传入的onlyIfAbsent参数为false,或者旧值为null则直接替换掉旧值
  49. if (!onlyIfAbsent || oldValue == null)
  50. e.value = value;
  51. afterNodeAccess(e);
  52. return oldValue;
  53. }
  54. }
  55. // 以上操作以及全部完成,并且已经成功插入或更改一个节点的值
  56. // 修改modCount的值,记录修改次数
  57. ++modCount;
  58. // 更新size
  59. // 判断插入新节点后是否需要扩容
  60. if (++size > threshold)
  61. resize();
  62. afterNodeInsertion(evict);
  63. return null;
  64. }

可以看到,由于JDK8中HashMap采用了数组+ 链表+ 红黑树结构,在插入数据时,不仅要考虑是否需要扩容,还需要考虑当前的具体情况,是采用链表的方法插入还是使用红黑树的方法插入,以及是否需要将链表转换为红黑树。总体来说,put()的执行逻辑为:

  • 首先计算哈希值找到应将其放入那个Bucket中
  • 如果此时没有发生了哈希冲突,则直接插入即可
  • 如果此时发生了哈希冲突,那么需要根据具体的情况进行处理:

    • 如果此时Bucket已经采用了红黑树,那么就使用红黑树的方法插入
    • 否则继续使用链表的方法插入,而且当插入后链表的长度到达设置的临界值时,还需要将链表转换为红黑树
  • 插入的过程中,如果存在重复的key,还需要将其value替换为新的值
  • 数据插入结束后,如果size超过了阈值,还需要进行扩容

理解了put()的实现后,再去看最后一个构造函数的实现就很轻松了。


4.2.3.2 get

get()的实现相比来说较为简单,源码如下:

  1. public V get(Object key) {
  2. Node<K,V> e;
  3. return (e = getNode(hash(key), key)) == null ? null : e.value;
  4. }

它也并没有直接实现,而是调用了getNode()方法,如下所示:

  1. final Node<K,V> getNode(int hash, Object key) {
  2. Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
  3. // 如果哈希表不为空,以及Bucket不为空
  4. if ((tab = table) != null && (n = tab.length) > 0 &&
  5. (first = tab[(n - 1) & hash]) != null) {
  6. // 首先看第一个节点是否是要找的相等节点
  7. if (first.hash == hash && // always check first node
  8. ((k = first.key) == key || (key != null && key.equals(k))))
  9. // 如果是,直接返回该节点
  10. return first;
  11. // 如果还有后续的节点,继续遍历
  12. if ((e = first.next) != null) {
  13. // 如果当前的桶是采用红黑树处理冲突,则调用红黑树的get方法去获取节点
  14. if (first instanceof TreeNode)
  15. return ((TreeNode<K,V>)first).getTreeNode(hash, key);
  16. // 不是红黑树的话,那采用链表的方法,循环判断链中是否存在该key
  17. do {
  18. if (e.hash == hash &&
  19. ((k = e.key) == key || (key != null && key.equals(k))))
  20. return e;
  21. } while ((e = e.next) != null);
  22. }
  23. }
  24. // 如果所有情况下都找不到,那么返回null
  25. return null;
  26. }

get()的执行逻辑同样需要区分此时是否已经采用了红黑树,如果是,则采用红黑树的方法进行处理,否则采用链表的方法处理。总体的执行逻辑如下:

  • 首先计算哈希值找到应将其放入那个Bucket中
  • 如果此时Bucket中的key就是传入的key,那么直接返回该节点
  • 否则继续查看后续节点

    • 如果已经采用了红黑树,使用红黑树的方法查找key
    • 否则采用链表的方法,循环遍历整个链表查找key

4.2.3.3 resize

JDK8中扩容操作虽然仍然是将其变为当前的2倍,但实现的方法更为的巧妙。因为每次扩容都是翻倍,与原来计算深入浅出的理解HsahMap的实现原理及常见面试题 (1) - 图20%20%5C%26%20%5C%20hash#card=math&code=%28n-1%29%20%5C%26%20%5C%20hash)的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到原位置+旧容量这个位置。

假设原来的容量为32,那么应该拿hash跟31(0x11111)做与操作;在扩容为64之后,应该拿hash跟63(0x111111)做与操作。新容量跟原来相比只是多了一个bit位,假设原来的位置在23,

  • 那么当新增的那个bit位的计算结果为0时,那么该节点还是在23
  • 反之,当计算结果为1时,则该节点会被分配到23+31的Bucket中

这样扩容后,每个Bucket中的节点数最多只能和之前的一样,而更大概率是比之前的少,这样就减少了哈希冲突的发生。resize()的源码实现如下:

  1. final Node<K,V>[] resize() {
  2. // 首先获取旧table
  3. Node<K,V>[] oldTab = table;
  4. // 获取旧table的容量
  5. int oldCap = (oldTab == null) ? 0 : oldTab.length;
  6. // 获取旧的阈值
  7. int oldThr = threshold;
  8. // 初始化新的容量和阈值为0
  9. int newCap, newThr = 0;
  10. // 如果旧table不为空
  11. if (oldCap > 0) {
  12. // 如果此时旧table已经达到了最大容量,则无法继续进行扩容
  13. if (oldCap >= MAXIMUM_CAPACITY) {
  14. threshold = Integer.MAX_VALUE;
  15. return oldTab;
  16. }
  17. else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
  18. oldCap >= DEFAULT_INITIAL_CAPACITY)
  19. // 否则将其扩充为原来的2倍
  20. newThr = oldThr << 1;
  21. }
  22. // 将旧的阈值作为新table的初始容量
  23. else if (oldThr > 0)
  24. newCap = oldThr;
  25. else {
  26. // 否则使用默认值
  27. newCap = DEFAULT_INITIAL_CAPACITY;
  28. newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  29. }
  30. // 计算新的阈值
  31. if (newThr == 0) {
  32. float ft = (float)newCap * loadFactor;
  33. newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
  34. (int)ft : Integer.MAX_VALUE);
  35. }
  36. // 更新当前阈值字段的值
  37. threshold = newThr;
  38. @SuppressWarnings({"rawtypes","unchecked"})
  39. // 使用扩容后的容量创建新的哈希表
  40. Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  41. table = newTab;
  42. if (oldTab != null) {
  43. // 遍历旧哈希表的每个Bucket,重新计算Bucket里元素的新位置
  44. for (int j = 0; j < oldCap; ++j) {
  45. Node<K,V> e;
  46. if ((e = oldTab[j]) != null) {
  47. oldTab[j] = null;
  48. // 如果桶上只有一个键值对,则直接插入
  49. if (e.next == null)
  50. newTab[e.hash & (newCap - 1)] = e;
  51. //如果是通过红黑树来处理冲突的,则调用相关方法把树分离开
  52. else if (e instanceof TreeNode)
  53. ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
  54. // 如果采用链式处理冲突
  55. else {
  56. Node<K,V> loHead = null, loTail = null;
  57. Node<K,V> hiHead = null, hiTail = null;
  58. Node<K,V> next;
  59. do {
  60. next = e.next;
  61. if ((e.hash & oldCap) == 0) {
  62. if (loTail == null)
  63. loHead = e;
  64. else
  65. loTail.next = e;
  66. loTail = e;
  67. }
  68. else {
  69. if (hiTail == null)
  70. hiHead = e;
  71. else
  72. hiTail.next = e;
  73. hiTail = e;
  74. }
  75. } while ((e = next) != null);
  76. if (loTail != null) {
  77. loTail.next = null;
  78. newTab[j] = loHead;
  79. }
  80. if (hiTail != null) {
  81. hiTail.next = null;
  82. newTab[j + oldCap] = hiHead;
  83. }
  84. }
  85. }
  86. }
  87. }
  88. // 最后返回扩容后的新table
  89. return newTab;
  90. }


5. 细节问题

经过前面对于HashMap的源码解读后,我们对于很多相关的问题应该是有所体会了。因此,这里趁热打铁来看一下所涉及的其他细节问题。


5.1 HashMap中存放自定义对象为什么要重写hashCode和equals方法?

HashMap在使用put方法存放新的键值对数据时,不经要比较哈希值,而且还要使用equals方法进行比较,这是因为hashCode可以帮助快速的找到数据在哈希表中的位置,但是位置相同的数据并不一定相等,还需要使用equals方法进行最终判断两个Entry对象是否相等。

那为什么使用equals进行判断,而不使用==进行判断呢?这里因为,==用于比较两个对象是否相等时,实际上比较的是对象的引用,知道两个对象的引用指向的是同一地址,那么就判断相等。而equals方法判断两个对象相等的标识是:两个对象的类型一致。并且内容一致。而这显然更符合HashMap中的需求。


5.2 哈希表采用LinkedList实现是否可以?

答案当然是可以的!只要是可以根据给定的索引进行数据的查找的数据结构,理论上都可以作为哈希表实现的结构基础。HashMap之所以采用的是数组实现,是因为此时数组的实现方式已经够用,效率相比更复杂的实现更高。因此,HashMap采用了数组实现的哈希表。


5.3 扩容时为什么要求是2的幂次形式?

至于为什么是2的幂次,在JDK8的resize部分已有初步说明,更详细的内容可以看出HashMap实现原理及源码分析相关部分的阐述,这里就不再重写了。


5.4 为什么当链表的长度超过8时才转换为红黑树?什么时候又退化为链表?

当哈希表中Bucket中的链表长度超过8时,就需要将其转换为红黑树,从而保证查询的高效性。之所以不是只使用红黑树,是因为当Bucket中数据较少时,使用红黑树带来的查询的效率提升无法抵消红黑树左旋、右旋、变色和操作带来的额外开销。因此,当数据个数小于8时,仍采用传统的链表。

而当不断的扩容等操作导致树中的元素个数等于6时,红黑树再次转换为链表。中间的差值7是为了两者之间频繁的转换,而带来的性能开销。如果数据数为7时为链表,就继续使用链表;如果数据数为7时为红黑树,就继续使用红黑树。只有当达到了转换的阈值时才进行转换操作。

那么能否使用更简单的二叉树呢?答案是不可以的。因为二叉树极端情况下相当于单链表,那么使用它的意义就没有了。


5.5 通常使用何种类型数据作为key?

HashMap使用哈希值来找到数据在哈希表中的存放位置,因此,哈希值变化越小越好。因此,通常使用String或Integer类型的数据作为key,这样它对应的哈希值在每次使用时不需要重新计算,可以实现快速的存取。

5.6 HashMap为什么是线程不安全的?

线程不安全问题出在不同的线程对同一个HaahMap进行put操作的过程中,往深了说主要出现在HashMap的扩容过程中。如果不同的线程对HashMap只是执行get操作,并不会涉及改变HashMap内部结构和存储数据的情况,那么即时再多的线程访问,也不活出现线程不安全问题。

然而,put操作可能会触发扩容。扩容执行过程中不仅需要将当前HashMap扩充为当前容量的2倍,而且还需要根据新的容量来改变索引的计算规则。根据新的计算规则,将之前已有的Entry类型或是Node类型的数据重新放入扩容后的HashMap中。但是,如果现在多个线程都触发了HashMap的扩容机制,那么由于HashMap底层对于哈希冲突采用的是链地址法,所有重新存放的过程中就需要进行链表的构建。Jdk1.7及之前HashMap采用的都是头插法,如果不同线程之间的操作不同,那么对于同一个索引地址的链表就可能会出现循环链表的情况,这就是出现了所谓的线程不安全问题。另外,也可能会出现数据丢失问题。

Jdk8在重新放置元素的过程中,对于相同哈希值的元素放置过程中,可能会出现后面放的元素将前面放的元素覆盖的情况,此时也认为是线程不安全。

总结:

  • 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。

  • 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。

详细的分析过程可见:

三太子敖丙 - 面试官:HashMap 为什么线程不安全?



6. 参考

Java 集合系列10之 HashMap详细介绍(源码解析)和使用示例

Java 8系列之重新认识HashMap

JDK8 HashMap 源码解析

HashMap面试指南

HashMap实现原理及源码分析

Java中HashMap的实现原理

一文解读JDK8中HashMap的源码