一、概览
HashMap<String, Integer> map = new HashMap<>();
这个语句执行起来,在 jdk1.8 之前,会创建一个长度是 16 的 Entry[]
数组,叫 table
,用来存储键值对。
在 jdk 1.8 后,不在这里创建数组了,而是在第一次 put
的时候才会创建数组叫 Node[] table
,用来存储键值对。
二、源码的成员变量分析
声明部分:
HashMap 实现了 Map 接口,又继承了 AbstractMap
,但是 AbstractMap
也是实现了 Map
接口的,而且很多集合类都是这种实现,这是一个官方失误造成的冗余,不过一直流传了下来。
- 继承
AbstractMap
,这个父类作为抽象类,实现了Map
的很多方法,为了减少直接实现类的工作; - 实现
Cloneable
接口和Serializable
接口,这个问题在 原型模式 里面说过,就是深拷贝的问题,但是值得注意的是,HashMap 实现这两个接口,重写的方法仍然不是深拷贝,而是浅拷贝。
属性部分:
2.1 序列号serialVersionUID
序列化默认版本号,不重要。
2.2 默认初始化容量DEFAULT_INITIAL_CAPACITY
集合默认初始化容量,注释里写了必须是 2 的幂次方数
,默认是 16。
问题 1 : 为什么非要是 2 的次方数呢?
答:第一方面为了均匀分布,第二方面为了扩容的时候重新计算下标值的方便。
这个涉及到了插入元素的时候对每一个 node 的应该在的桶位置的计算:
核心在这个方法里,会根据 (n - 1) & hash
这个公式计算出 i
,hash
是提前算出的 key
的哈希值,n
则是整个 map
的数组的长度。
那么这个节点应该放在哪个桶,这就是散列的过程,我们当然希望散列的过程是尽量均匀的,而不会出现都算出来进入了 table[]
的同一个位置。那么,可以选择的方法有取余啊、之类的,这里采用的方法是位运算来实现取余。
就是(n - 1) & hash 这个位运算,2 的幂 -1 都是11111结尾的:
2 进制,所以 2 的几次方都是 1 00000(很多个 0 的情况),然后 -1, 就会变成 000 11111(很多个1)那么和 本来计算的具有唯一性的 hash 值相与,
- 用高位的 0 把hash 值的高位都置为了 0 ,所以限制在了 table 的下标范围内。
- 保证了 hash 值的尽量散开。
对于第 2 点,如果不是 2 的幂次方,那么 -1 就不会得到 1111 结尾,甚至如果是个基数,-1 后就会变成形如 0000 1110
这样的偶数,那么相与的结果岂不是永远都是偶数了?这样 table 数组就会有一半的位置永远利用不上的。所以 2 的幂次方以及 -1 的操作,才能保证得到和取模一样的效果。
因此得出结论,如果 n 是 2 的幂次方,计算出的位置会很均匀,相反则会干扰这个运算,导致计算出的位置不均匀。
第二个方面的原因就是扩容的时候,重新要计算下标值 hash
,2 的幂次方
带给了好处,下面的扩容部分有详细说明。
注意到我们初始化 HashMap 的时候可以指定容量。
问题 2 那么如果传入的容量并不是 2 的次方,怎么办呢?
从构造方法可以看到,调用指定加载因子和 容量的方法,如果大于最大容量,就会改为最大容量,接着对于容量,调用 tableSizeFor
方法,此时传入的参数已经肯定是 <=
最大容量的数字了。
tableSizeFor
这个方法会产生一个大于传入数字的、最小的 2
的幂次方数。
2.3 最大容量MAXIMUM_CAPACITY
最大 hashMap 的容量就是 1 左移 30 位,也就是 2 的 30 次方
。
2.4 默认加载因子DEFAULT_LOAD_FACTOR
默认加载因子为 0.75
,也就是说,如果键值对超过了当前的容量 * 0.75
,就会触发扩容。
问题 为什么是 0.75
而不是别的数呢?
答:如果加载因子越大,对空间的利用更充分,但是查找效率会降低(链表长度会越来越长);如果加载因子太小,那么表中的数据将过于稀疏(很多空间还没用,就开始扩容了),对空间造成严重浪费。
其实 0.75
是一个统计的结果,比较理想的值,根据旧版源码里面的注释,和概率的泊松分布有关系,当负载因子是 0.75
的情况下,哈希碰撞的概率遵循参数约为 0.5
的泊松分布,因此选择它是一个折衷的办法来满足时间和空间。
2.5 转树的阈值TREEIFY_THRESHOLD
默认为 8
,也就是说一个桶内的链表节点数多于 8
的时候,结合数组当前长度会把链表转换为红黑树。
问题 为什么是超过 8
就转为红黑树?
答:首先,红黑树的节点在内存中是普通链表节点方式存储的 2 倍
,成本是比较高的,那么对于太少的节点数目就没必要转化,继续扩容就行了。
结合负载因子 0.75
的泊松分布结果,每个链表有 8
个节点的概率已经到达可以忽略的程度,所以将这个值设置为 8
。为了避免出现恶意的频繁插入,除此之外还会判断数组长度是否达到了 64。
所以到这里我个人的理解是:
-> 最开始hashmap的思想就是数组加链表;
-> 因为数组里的各个链表长度要均匀,所以就有了哈希值的算法,以及适当的扩容,扩容的加载因子定成了 0.75 ;
-> 而扩容只能根据总共的节点数来计算,可能没来得及扩容的时候还是出现了在同一个链表里元素变得很多,所以要转红黑树,而这个数量就根据加载因子结合泊松分布的结果,决定了是8.
2.6 重新退化为链表的阈值UNTREEIFY_THRESHOLD
默认为 6
, 也就死说如果操作过程发现链表的长度小于 6
,又会把树退回链表。
2.7 转树的最小容量
不仅仅是说有链表的节点多于 8
就转换,还要看 table
数组的长度是不是大于 64
,只有大于 64
了才转换。为了避免开始的时候,正好一些键值对都装进了一个链表里,那只有一个链表,还转了树,其实没必要。
还有属性的第二部分:
第一个是容器 table
存放键值对的数组,就是保存链表或者树的数组,可以看到 Node
类型也是实现了 Entry
接口的,在 1.8
之前这个节点是不叫 Node
的,就叫的 Entry
,因为就是一个键值对,现在换成了 Node
,是因为除了普通的键值对类型,还可能换成红黑树的树节点TreeNode
类型,所以不是 Entry
了。
第二个是保存所有键值对的一个 set
集合,是一个存放缓存的;
第三个 size
是整个hashmap
里的键值对的数目;
第四个是 modCount
是记录集合被修改的次数,有助于在多个线程操作的时候报根据一致性保证安全;
第五个 threshold 是扩容的阈值,也就是说大于阈值的时候就开始扩容,也就是 threshold = 当前的 capacity * loadfactor
;
第六个 loadFactor
也是对应前面的加载因子。
三、源码的核心方法分析
3.1 构造方法
可以看到,这几个重载的构造方法做的事就是设置一些参数。
事实上,在 jdk1.8 之后,并不会直接初始化 hashmap
,只是进行加载因子、容量参数的相关设定,真正开始将 table
数组空间开辟出来,是在 put
的时候才开始的。
第一个:
public HashMap()
是我们平时最常用的,只是设置了默认加载因子,容量没有设定,那显然就是 16
。
第二个:
public HashMap(int initialCapacity)
为了尽量少扩容,这个构造方法是推荐的,也就是指定 initialCapacity
,在这个方法里面直接调用的是
第三个构造方法:
public HashMap(int initialCapacity, float loadFactor)
用指定的初始容量和加载因子,确保在最大范围内,也调整了 threshold 容量是 2 的幂次方数
。
这里就是一个问题,把 capcity
调整成 2 的幂次方
数,计算 threshold
的时候不应该要乘以 loadfactor
吗,怎么能直接赋给 threshold
呢?
原因是这里没有用到 threshold
,还是在 put
的时候才进行 table
数组的初始化的,所以这里就没有操作。
最后一个构造方法是,将本来的一个 hashmap 放到一个新的 map 里。
3.2 put 和 putVal 方法
put
方法是直接调用了计算 hash
值的方法计算哈希值,然后交给 putVal
方法去做的。
hash
方法就是调用本地的 hashCode
方法再做一个位移操作计算出哈希值。
为什么采用这种右移 16 位
再异或的方式计算 hash
值呢?
因为 hashCode
值一般是一个很大的值,如果直接用它的话,实际上在运算的时候碰撞的概率会很高,所以要充分利用这个二进制串的性质:int
类型的数值是 4
个字节的,右移 16
位,再异或可以同时保留高 16 位
和低 16 位
的特征,进行了混合得到的新的数值中,高位与低位的信息都被保留了 。
另外,因为,异或运算能更好的保留各部分的特征,如果采用 &
运算计算出来的值会向 1
靠拢,采用 |
运算计算出来的值会向 0
靠拢, ^
正好。
最后的目的还是一样,为了减少哈希冲突。
算出 hash 值后,调用的是 putVal 方法:
传入哈希值;要插入的 key 和 value;然后两个布尔变量,onlyIfAbsent 代表当前要插入的 value 是否存在了如果是 true,就不修改;evict 代表这个 hashmap 是否处于创建模式,如果是 false,就是创建模式。
下面是源码及具体注释:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[