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、构造函数
通过源码可以看出总共有四个构造函数:
可以看出,构造函数涉及到两个参数:loadFactor
和initialCapacity
,在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 线程不安全的原理。