品HashMap(java8)

70 阅读9分钟


前言

作为

java

开发人员,
HashMap
可谓是业务中的一把利器,
9
龙再次捡起这老生常谈的知识点,深入源码,细细品味。

首先,我们抛出几个关于

HashMap

的问题,带着问题去学习,就像捉迷藏一样有意思。

1

、为什么要使用
HashMap
HashMap
有什么特性?

2

HashMap
的主要参数有哪些?都有什么作用?

3

HashMap
是基于什么数据结构实现的?

4

、构造
HashMap
时传入的初始容量是如何处理的?为什么要这样做?

5

HashMap
在什么时候扩容?扩容的时候都做了什么事?
hash
碰撞
8
次一定会转换为红黑树吗?

6

、在
foreach
时对
hashMap
进行增删操作会发生什么?

1

、为什么要使用
HashMap?

我们在使用一种工具的时候,肯定是因为其的某种特性很符合我们的需求,能够快速准确的解决我们的问题。那我们为什么要使用

HashMap

呢?

This implementation provides constant-time performance for the basic operations (get and put), assuming the hash function disperses the elements properly among the buckets.

源码注释里有这样一句话,这就是我们使用

HashMap

的原因。

意为:

HashMap

为基本操作
(get
put)
提供了常数时间性能(即
O(1)
),假设散列函数将元素适当地分散到各个
bucket
中。

我们可以这样理解,如果当你需要快速存储并查询值,可以使用

HashMap

,它可以保证在
O(1)
的时间复杂度完成。前提是你键的
hashCode
要足够不同。

Map

还有一个特性就是
key
不允许重复。下面我们就来看看
HashMap
如何保证
O(1)
进行
get
put

2

、细嚼
HashMap
主要参数
2.1
、静态常量
//
默认的初始化桶容量,必须是
2
的幂次方(后面会说为什么)
staticfinal int DEFAULT_INITIAL_CAPACITY = 1 << 4; //
最大桶容量
static final int MAXIMUM_CAPACITY = 1 << 30; //
默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f; //
判断是否将链表转化为树的阈值
static final int TREEIFY_THRESHOLD = 8; //
判断是否将树转化为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6; //
判断是否可以执行将链表转化为树,如果当前桶的容量小于此值,则进行
resize()
。避免表容量过小,较容易产生
hash
碰撞。
static final int MIN_TREEIFY_CAPACITY = 64;2.2
、字段
//hash
transient Node<K,V>[] table; //
缓存的
EntrySet
,便与迭代使用
transient Set<Map.Entry<K,V>> entrySet; //
记录
HashMap
中键值对的数量
transient int size; //
当对
hashMap
进行一次结构上的变更,会进行加
1
。结构变更指的是对
Hash
表的增删操作。
transient int modCount; //
判断是否扩容的阈值。
threshold = capacity * load factor int threshold; //
负载因子,用于计算
threshold
,可以在构造函数时指定。
final float loadFactor;3
、嗅探
HashMap
数据结构

上面我们看到一个

Node<K,V>[] table

Node
数组。

为什么要使用数组呢?

答:为了能快速访问元素。哦,说的什么鬼,那我得追问,为什么数组能快速访问元素了?

数组只需对

[

首地址
+
元素大小
*k]
就能找到第
k
个元素的地址,对其取地址就能获得该元素。

CPU

缓存会把一片连续的内存空间读入,因为数组结构是连续的内存地址,所以数组全部或者部分元素被连续存在
CPU
缓存里面。

让我们看看

Node

的结构。

static class Node<K,V> implements Map.Entry<K,V> { final int hash; //key

hash final K key; //key
对象
V value; //value
对象
Node<K,V> next; //
链接的下一个节点
Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } }

我们看到,

Node

节点内部保留了一个
next
节点的引用,太熟悉了,这不就是链表嘛。

到这,我们知道了

HashMap

的底层数据结构是基于数组
+
链表。但是,这就完了吗?在
jdk1.7
确实只是这样,
jdk1.8
为了提高
hash
碰撞时链表查询效率低的问题,在
hash
碰撞达到
8
次之后会将链表转化为红黑树,以至于将链表查询的时间复杂度从
O(N)
提高到
O(logN)

到这我们就可以明白,

HashMap

如果能够均匀的将
Node
节点放置到
table
数组中,我们只要能够通过某种方式知道指定
key
Node
所在数组中的索引,基于数组,我们就可以很快查找到所需的值。

接着我们就要看看如何定位到

table

数组中。

4

、走进
HashMap
构造函数

有了上面的基础知识,知道字段含义及数据结构,我们就有一点信心可以正式进入源码阅读。我觉得了解一个类,得从构造函数入手,知道构造对象的时候做了哪些初始化工作,其次再深入常用的方法,抽丝剥茧。

public HashMap(int initialCapacity) { //

如果只传入初始值,则负载因子使用默认的
0.75 this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); //
保证初始容量最大为
2^30 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); //
使用指定的值初始化负载因子及判断是否扩容的阈值。
this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }

我们可以看到,构造函数主要是为了初始化负载因子及

hash

表的容量。可能大家会疑问,这不是初始化的是
threshold
吗?不要被表面所欺骗,这只是临时将
hash
表的容量存储在
threshold
上,我想是因为
HashMap
不想增加多余的字段来保存
hash
表的容量,因为数组的
length
就可以表示,只是暂时数组还未初始化,所以容量暂先保存在
threshold

我们看到将用户指定的

initialCapacity

传入
tableSizeFor
方法返回了一个值,返回的值才是真正初始化的容量。???搞毛子这是?然我们揭开它神秘的面纱。

/** * Returns a power of two size for the given target capacity. */static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }

好吧,
我们还是把它盖上吧,

9

龙也没去推算过。我们从
jdk
给的方法注释看出,该方法返回一个目标值的
2
的幂次方,进一步
9
龙翻译为:返回大于或等于目标值的第一个数,该数必须是
2
的幂次方。

举例说一下:

如果输入

10

,大于等于
10
的第一个数,又是
2
的幂次方的数是
16

如果输入

7

,大于等于
7
的第一个数,又是
2
的幂次方的数是
8

如果输入

20

;大于等于
20
的第一个数,又是
2
的幂次方的是
32

到这我们又得问自己,为什么

hash

表的容量必须是
2
的幂次方呢?

5

、解剖
HashMap
主要方法
5.1
put

当我们

new

HashMa
的对象,都会调用
put
方法进行添加键值对。我跟那些直接贴代码的能一样吗?有啥不一样,哈哈哈。
9
龙会先读源码,再贴流程图,这样大家会更理解一点。

public V put(K key, V value) { return putVal(hash(key), key, value, false, true);}static final int hash(Object key) { int h; //

key
的高
16
位与低
16
位异或,减小
hash
碰撞的机率
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }

让我们看看

putVal

干了什么。

/** *

此方法用于将
(k,v)
键值对存储到
HashMap
* * @param hash key
hash * @param key key
对象
* @param value key
对应的
value
对象
* @param onlyIfAbsent
如果是
true,
则不覆盖原值。
* @param evict if false, the table is in creation mode. * @return
返回旧值,如果没有,则返回
null
*/ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //
在第一次
put
的时候,此时
Node
表还未初始化,上面我们已经知道,构造
HashMap
对象时只是初始化了负载因子及初始容量,但并没有初始化
hash
表。在这里会进行第一次的初始化操作。
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //
如果得到了一个
hash
值,并且
hash
值在很少相同的情况下,如何均匀的分布到
table
数组里呢?最容易想到的就是用
hash%n
n
table
数组的长度。但是
%
运算是很慢的,我们知道位运算才是最快的,计算机识别的都是二进制。所以如果保证
n
2
的幂次方,
hash%n
hash&(n-1)
的结果就是相同的。这就是为什么初始容量要是
2
的幂次方的原因。
//
当找到的
hash
桶位没有值时,直接构建一个
Node
进行插入
if ((p = tab[i = (n - 1) & hash]) == null) tab = newNode(hash, key, value, null); else { //
否则,表明
hash
碰撞产生。
Node<K,V> e; K k; //
判断
hash
是否与桶槽的节点
hash
是否相同并且
key
equals
方法也为
true,
表明是重复的
key
,则记录下当前节点
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //
如果桶槽节点是树节点,则放置到树中,并返回旧值
else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //
表明是链表,还未转换为红黑树。
for (int binCount = 0; ; ++binCount) { //
如果节点的
next
索引是
null
,表明后面没有节点,则使用尾插法进行插入
if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //
此时链表长度为
9
,即
hash
碰撞
8
次,会将链表转化为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //
如果
key
是同一个
key,
则跳出循环链表
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //
判断是否是重复的
key if (e != null) { // existing mapping for key //
拿到旧值
V oldValue = e.value; //
因为
put
操作默认的
onlyIfAbsent
false
,所以,默认都是使用新值覆盖旧值
if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); //
返回旧值
return oldValue; } } //
到这里,表明有新数据插入到
Hash
表中,则将
modCount
进行自增
++modCount; //
判断当前键值对容量是否满足扩容条件,满足则进行扩容
if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }

总结一下:

put

方法先通过计算
key
hash
值;

如果

hash

表没有初始化,则进行初始化;

然后计算该

hash

应该处于
hash
桶的哪个位置;

如果该位置没有值,则直接插入;

如果有值,判断是否为树节点,是的话插入到红黑树中;

否则则是链表,使用尾插法进行插入,插入后判断

hash

碰撞是否满足
8
次,如果满足,则将链表转化为红黑树;

插入后判断

key

是否相同,相同则使用新值覆盖旧值;

进行

++modCount

,表明插入了新键值对;再判断是否进行扩容。、