深入浅出HashMap详解(JDK7)

1、什么是HashMap

  java.util.HashMap是一个用于存储键值对的集合,每一个键值对称为 Entry。这些键值对分散存储在一个数组当中,这个数组就是 HashMap 的主干。

  基本使用:

public class HashMapTest {
    public static void main(String[] args) {
        HashMap<String,Integer> map = new HashMap<>();
        map.put("klb",18);
        map.put("smm",12);
        System.out.println(map);
    }
}

  运行结果:
在这里插入图片描述
  HashMap 数组每一个元素的初始值都是 Null。
在这里插入图片描述

2、构造函数

  通过源码可以看出总共有四个构造函数:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
  可以看出,构造函数涉及到两个参数:loadFactorinitialCapacity,在java.util.HashMap中有两个属性:
在这里插入图片描述
在这里插入图片描述
  当两个参数没有指定值,则有默认值。两个参数分别叫做加载因子和初始化容量。

  这两个参数是干嘛的呢?

  在构造函数中只看到这些参数赋值给了属性,但没有使用,更没有看到有创建数组的地方。不是说 HashMap 是数组加链表么?

  其实,HashMap 可以看成懒加载,当没有数据 put 进来的时候,是不会创建数组的,防止浪费空间。

3、table 的创建

  我们使用map.put("klb",18)就把数据插入到 HashMap 当中了,这中间发生了什么呢?

  我们查看源码:
在这里插入图片描述
  table 指的就是 HashMap 的数组部分,第一个 if 判断就是判断这个 table 是否创建了,如果没有创建,则创建一个,我们进入这个inflateTable(threshold)查看源码:
在这里插入图片描述
  创建 table 的过程:

  1、没有指定的情况下,默认的loadFactor为0.75,默认的initialCapacity为16。首先是对 table 的容量进行纠正:
在这里插入图片描述
  这里的capacity就是大于等于toSize的数字中,最近的那个 2 的幂。默认值是 16 ,因此这里输出也是 16。如果在构造器中输入了initialCapacity为 18,那么大于 18 又是最近的 2 的幂,只能是 32,这个函数会返回 32 。总之,这个roundUpToPowerOf2就是为了保证 table 的容量既能满足程序员的要求,又必须是 2 的幂。

  3、更新阈值为capacity * loadFactor
在这里插入图片描述
  后面那个MAXIMUM_CAPACITY值为 1<<30,也就是 230= 1,073,741,824,一般是达不到这么大的数值。我们可以看成阈值就是:table 的容量和负载因子的乘积。
在这里插入图片描述
  4、确定了 table 的容量,接着就是直接创建容量大小的数组:
在这里插入图片描述
  可以得出:table 是一个 Entry[] 类型的数组,那 Entry 又是什么呢?查看 Entry 的定义:
在这里插入图片描述
  Entry 就是我们操作的键值对,他的 key 是 final 类型表示不可修改,有 next 属性表示可以多个 Entry 连城一个链表,带有 hash 值。

  因此,我们可以得出结论:JDK7 的 HashMap 就是数组+链表的形式。

4、Put 方法

  继续看源码:
在这里插入图片描述
  对键值对的 key 先通过hash()方法得到 hash 值,然后调用indexFor(hash, length)方法计算出当前 Entry 要插入在 table 的哪一个位置。我们来看这两个方法的源码:
在这里插入图片描述
  hash(key)方法是计算对应 key 的哈希值,有兴趣的可以查询其他资料来学习这个函数的过程,这里只介绍indexFor方法。我们可以看到,这个方法对哈希值进行h & (length-1)操作,length 就是 table 的长度,为什么要这个与操作呢?

  通过 table 的创建过程可以知道:length 是 2 的幂,一个 2 的幂的数字,减去 1 之后,展开成二进制就会是全是 1。比如,如果 length 为 16,那么 15 的二进制表示就是 1111 1111。而 length 是 int 类型的,即 32 位。如果 length 为 16,h 为 749,则这个 indexFor 操作为:

0000 0000 0000 0000 0000 0010 1110 1101
&
0000 0000 0000 0000 0000 0000 1111 1111
=
0000 0000 0000 0000 0000 0000 1110 1101

  其实就是说,length 数值为多少,就取 hash 低多少位的值,这个值的范围肯定是 0 到 length - 1。肯定能放到 table 的某个位置上。

  继续看put下面的源码:
在这里插入图片描述
  找到了要插入的位置 i,下面就是先看 table[i] 是不是空的,如果是空的则忽略 for 循环准备插入。如果不是空的,则遍历 table[i] 的 Entry ,如果待插入的 key 已经存在,新的 value 覆盖旧的 value,并且返回 value。如果在 for 当中没有 return,即说明 table 中可以插入这个 Entry,下一步准备插入。

  继续往下看:
在这里插入图片描述
  这个modCount通过定义当中的注释可知是 HashMap 的修改次数。
在这里插入图片描述
  接着进入addEntry(hash, key, value, i)方法:
在这里插入图片描述
  if 里判断当前 table 里面拥有的 Entry 数量是否超过阈值,前面说过,这个阈值就是 table 容量乘以负载因子,比如下载阈值为 16 × 0.75 = 12。

  先不看 if,假设此时 table 里面的容量还没达到 阈值,则进行createEntry方法:
在这里插入图片描述
  这里有一个细节:先把 table[i] 当前位置的 Entry 拿出来,然后放到 待插入的 Entry 的 next 中,然后这个新插入的 Entry 直接放到 table[i] 当中,最后 size++。也就是说,后面插入的 Entry ,会占在 table[i] 里,这就是所谓的头插法。区别于插在以后链表的后面,那个叫尾插法,也是最常见的插法。
  插入前:
在这里插入图片描述
  插入后:
在这里插入图片描述
  现在我们回头看addEntry的 if 代码块:
在这里插入图片描述
  如果 table 已有元素大于阈值,并且当前待插入的位置 table[i] 已有元素。则进行扩容。很多人误以为达到阈值就扩容,还有一个条件很容易让人忽略,就是待插入的位置必须不为空,如果待插入的位置是空的,哪怕已经到了阈值,也是直接插入不扩容。

  扩容的细节在下面详细分析,这一小节主要讲put方法的详细过程。

5、Get 方法

  Get 方法源码:
在这里插入图片描述
  首先是判断 key 的情况,如果传入的 key 是一个 null,则从 table[0] 当中取值:
在这里插入图片描述
  遍历 table[0] 的 Entry 链表,找到 key 为 null 的返回。

  这里你可能会好奇:为什么 key 为 null 就得跑到 table[0] 里面找呢?

  我们回顾一下,key 是如何获取 table 的 index 呢,肯定就是那个has()啦,我们回头看这个方法的源码:
在这里插入图片描述
  看注释就知道,null 的哈希值永远为 0,因此放在 table[0] 的位置。

  继续看下一步:
在这里插入图片描述
在这里插入图片描述
  可以看出设计者的厉害,知道了要查询的 key 在 table 的哪一个位置后,开始遍历这个位置的链表,光判断 key 是否相等,就用了这么多重判断:
在这里插入图片描述
  首先得判断待查询的 key 和遍历到的 Entry 的 key 的哈希值是否一样,然后判断两个 key 是否一样,都一样才会返回这个 Entry。
在这里插入图片描述
  通过源码知道:很多帖子说先通过 key 找到位置,然后从位置中找到匹配的 key 的 Entry,就返回这个 Entry,其实不准确。因为源码里是先匹配哈希值,哈希值一样才再匹配一次 key 值。

6、扩容

  回顾:在插入一个新的键值对时,我们在put方法中知道,它会:
  1、计算 key 的哈希值,然后根据哈希值和 table 的长度计算出待插入的位置 i;
在这里插入图片描述
  2、判断这个位置 i 是否有和待插入的 key 相同的值,如果有,则返回 table 中和待插入 key 相同的 Entry 的 value;
在这里插入图片描述
  3、如果 table[i] 没有和待插入相同的 key ,则先对修改次数modCount加一,然后调用addEntry(hash, key, value, i)准备插入。

  确定真的可以插入,HashMap 也没有马上插入,它要先检查当前的 table 状况是否可以插入:
在这里插入图片描述
  可以看出,当满足 if 条件时,会进行 resize 扩容。下面详细介绍这个扩容原理。

  第一步先明确的就是:扩容是直接容量变为两倍:
在这里插入图片描述
  先把旧 table 的数据和长度临时保存起来:
在这里插入图片描述
  接着判断旧的 table 容量是否已经处于可设置的上限:
在这里插入图片描述
  如果旧的 table 的容量已经达到可设置的上限,那么阈值直接变为 int 类型的最大值,也就是以后触发阈值的机会变得很少。然后直接 return,即不扩容,要爆满就爆满吧。通过把阈值设置成特别大,也减少进入这个 resize 的机会。
在这里插入图片描述
在这里插入图片描述
  如果旧 table 的容量尚未达到可设置的最大值,那么说明可以进行 resize,进行下一步,创建新的 EntryTable 数组:
在这里插入图片描述
  然后把旧的 table 的元素放到 newTable 之中:
在这里插入图片描述
  最后更新 table 为 newTable ,同时阈值也重新设置。
在这里插入图片描述
  transfer即遍历旧的 table 的所有Entry ,然后重新计算哈希值,放到 newTable 中对应的位置:
在这里插入图片描述

7、并发下线程不安全

  我们上面分析了那么多源码,从来没看到任何同步机制在里面,当多个线程并发操作一个 HashMap 时,会引发线程不安全问题。

  那线程不安全会提现在哪里呢?

  首先,get方法肯定不会带来线程安全问题,问题就出在put方法中,上面仔细研究代码,如果两个线程要put的 key 是不同的,其实也没有线程不安全问题;如果要put的 key 是相同的,那更没有问题了,因为 HashMap 是保证 key 唯一的,后插入的数据直接覆盖前面插入的,不会出现两个一样 key 的数据。

  经过分析,线程不安全就出在 resize 当中。

  当两个线程操作一个 HashMap,且两个线程都判断出应该 resize 了,其中一个线程已经完成了 resize,并且把 table 更新为新的 newTable,但是另一个线程还处于transfer方法中,最后会导致循环引用的问题。

  下面通过例子来说明:
  1、两个线程都要插入数据
在这里插入图片描述
  2、插入后变成如下:
在这里插入图片描述
  3、接着两个线程又准备插入数据,而且两个线程要插入的位置都是在 table[2] ,此时触发 resize 条件,两个线程都进入了 resize 操作:
在这里插入图片描述
  4、线程 A 眼疾手快,已经完成了 resize 并且更新了 table 为最新,线程 B还刚开始 transfer:
在这里插入图片描述
  5、线程 B 在 transfer 过程中,就出现了一下情况:
在这里插入图片描述
  等线程 B 更新了 table 后,table 就变为以下情况:
在这里插入图片描述
  后面要执行get的时候,就进入死循环了。

  这就是 JDK7 下的 HashMap 线程不安全的原理。

  • 38
    点赞
  • 68
    收藏
    觉得还不错? 一键收藏
  • 9
    评论
HashMap 是一种哈希表数据结构,它实现了 Map 接口,可以存储键值对。下面是 JDK 8 中 HashMap 的源码详解。 1. 基本概念 哈希表是一种基于散列原理的数据结构,它通过将关键字映射到表中一个位置来访问记录,以加快查找的速度。在哈希表中,关键字被映射到一个特定的位置,这个位置就称为哈希地址或散列地址。哈希表的基本操作包括插入、删除和查找。 2. 类结构 HashMap 类结构如下: ``` public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { ... } ``` HashMap 继承了 AbstractMap 类,并实现了 Map 接口,同时还实现了 Cloneable 和 Serializable 接口,表示该类可以被克隆和序列化。 3. 数据结构 JDK 8 中的 HashMap 采用数组 + 链表(或红黑树)的结构来实现哈希表。具体来说,它使用了一个 Entry 数组来存储键值对,每个 Entry 对象包含一个 key 和一个 value,以及一个指向下一个 Entry 对象的指针。当多个 Entry 对象的哈希地址相同时,它们会被放入同一个链表中,这样就可以通过链表来解决哈希冲突的问题。在 JDK 8 中,当链表长度超过阈值(默认为 8)时,链表会被转化为红黑树,以提高查找的效率。 4. 哈希函数 HashMap 的哈希函数是通过对 key 的 hashCode() 方法返回值进行计算得到的。具体来说,它使用了一个称为扰动函数的算法来增加哈希值的随机性,以充分利用数组的空间。在 JDK 8 中,HashMap 使用了以下扰动函数: ``` static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } ``` 其中,^ 表示按位异或,>>> 表示无符号右移。这个函数的作用是将 key 的哈希值进行扰动,以减少哈希冲突的概率。 5. 插入操作 HashMap 的插入操作是通过 put() 方法实现的。具体来说,它会先计算出 key 的哈希值,然后根据哈希值计算出在数组中的位置。如果该位置是空的,就直接将 Entry 对象插入到该位置;否则,就在该位置对应的链表(或红黑树)中查找是否已经存在具有相同 key 的 Entry 对象,如果存在,则更新其 value 值,否则将新的 Entry 对象插入到链表(或红黑树)的末尾。 6. 查找操作 HashMap 的查找操作是通过 get() 方法实现的。具体来说,它会先计算出 key 的哈希值,然后根据哈希值计算出在数组中的位置。如果该位置为空,就直接返回 null;否则,就在该位置对应的链表(或红黑树)中查找是否存在具有相同 key 的 Entry 对象,如果存在,则返回其 value 值,否则返回 null。 7. 删除操作 HashMap 的删除操作是通过 remove() 方法实现的。具体来说,它会先计算出 key 的哈希值,然后根据哈希值计算出在数组中的位置。然后,在该位置对应的链表(或红黑树)中查找是否存在具有相同 key 的 Entry 对象,如果存在,则将其删除,否则什么也不做。 8. 总结 以上就是 JDK 8 中 HashMap 的源码详解。需要注意的是,哈希表虽然可以加快查找的速度,但是在处理哈希冲突、扩容等问题上也存在一定的复杂性,因此在使用 HashMap 时需要注意其内部实现细节,以便更好地理解其性能和使用方法。
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值