数据结构--HashMap实现原理及解析
HashMap定义
HashMap
是基于Map
接口实现的一种键-值对<key,value>
的存储结构。内部允许null
值,同时非有序,非同步(线程不安全)。它存储和查找数据时,是根据key
的hashcode
计算出具体的存储位置。内部最多允许一条记录的key
为null
。
HashMap
的底层实现是数组+链表+红黑树(Java 8新增的)。
数组是HashMap的主体 所以HashMap的容量指的就是 数组的长度。HashMap的size指的就为存储键值对数量。
链表主要为了解决
Hash冲突
而存在的常用解决Hash冲突的方法有四种:
开放地址法--线性探测
:一般是在散列函数的基础上采取另一种算法,从而找到下一个空的数组位置,再将新数据填充进去。从而有效利用原数组空间。若整个空间都找不到空余的地址,则产生溢出。链地址法(拉链法)
:基本思路是全部具有同样哈希地址的而不同的Key的数据元素连接到同一个链表中
。加入在某个位置发生Hash冲突
,就将新数据以链表的形式接在已有数据的后面。HashMap1.7 是头插法,冲突的数据放在链表前端;HashMap在1.8之后是尾插法,冲突的数据放在链表尾端。- 优点:无堆积现象存在,平均查找长度较短;节点空间是动态申请的,适用于无法缺点表长的情况;装填因子较大时,拉链法中增加的指针空间可忽略不计;删除节点的操作易于实现。
- 缺点:指针需要额外的空间。
再哈希法
:同时构造多个不同的hash函数,直到不出现冲突为止。建立公共溢出区
:将哈希表分为两部分:基本表和溢出表。所有冲突的数据都放到溢出表中。
当链表长度大于阈值(一般为8)时,会转换成红黑树,减少搜索时间(最坏时间复杂度为 $ O(nlogn) $)
HashMap中的重要参数分析
1 |
|
capacity 容量
:必须是2的幂 并且小于 MAXIMUM_CAPACITY
$2^{30}$。默认容量为16,如果不设置初始容量的话。
为什么要转换为 $2^n$?
可以提高取余的效率
。为了防止链表过长,要保证键值对在数组中尽可能均匀分布。确定元素位置的方法是通过hash%length(table长度)
计算得出的。但是单纯的取余方式消耗相对较大,由于通过位运算hash & (length-1)
得到的结果是一样的。一个数对$ 2^n $取余,就是要去这个数二进制的最低n位。有利于提高计算元素存放位置的效率
。可以有效降低Hash冲突
几率。
1 |
|
loadFactor
:HashMap在其容量增加前可达到的最大负载。
LoadFactor取值范围为0~∞,当为0时会抛出IllegalArgumentException异常。
主要分两种情况分析:
loadFactory偏大
:则HashMap
装载程度就会越高。意味着可以容纳更多的元素,空间利用率就会变高。但元素多了,发生Hash冲突
的几率就会越大,从而链表会拉长,查询效率就会变低。loadFactory偏小
:则HashMap
装载程度就会变低,容纳的元素就会变少,空间利用率就会变低。但是发生Hash冲突
的几率变低,并且链表长度也会较短,提高查询效率。由于会发生频繁的扩容操作,对性能也会有影响。合理的设置
loadFactory
:
- 关心内存的话,采用
时间换空间策略
,适当的加大加载因子,牺牲查询速度,来换取更大的使用空间。- 关心时间的话,采用
空间换时间策略
,适当的减小加载因子,从而提高查询性能,但需要考虑到频繁扩容带来的性能消耗。
threshold
:扩容阈值。当哈希表的大小 >= 扩容阈值时,就会进行扩容操作。例如capacity设置16,loadFactory设置0.75,则阈值为12。当存储元素个数>12时,触发扩容。
计算方式为
capacity * loadFactor
。
扩容
:对哈希表进行resize
操作,扩大到原先的两倍表格大小。
1 |
|
TREEIFY_THRESHOLD
:当链表长度大于该值时,链表就会转换成红黑树。
UNTREEIFY_THRESHOLD
:当红黑树节点小于该值时,红黑树会转换回聊表。发生在resize()
扩容时。
MIN_TREEIFY_CAPACITY
:当哈希表中的容量大于该值时,才允许链表转换红黑树。
1 |
|
HashMap源码解析
HashMap初始化
1 |
|
- 在初始化
HashMap
中,只是进行了初始变量的赋值,还未进行table
的设置- 真正初始化哈希表(table)是在第一次调用
put()
时。这个就是lazy-load 懒加载
,直到被首次使用时,才会进行初始化。
HashMap插入数据 - put()
向
HashMap
中插入数据
1 |
|
put()
源码
1 |
|
在put()
中,实现分为了两步:
hash()
:将key
转化成hash值
。通过扰动函数
生成对应hash值
。1
2
3
4static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}当
key==null
时,hash值
为0 ,所以可以允许key
设置为null,不过后续都会覆盖原值。当
key!=null
时,先获取原key的hashcode()
,然后对其进行扰动处理: 按位异或(^) 再自身右移16位。所有处理的根本目的:为了提高 存储键值对 的数组下标位置的随机性&分布均匀性,尽量避免出现Hash冲突。
putVal()
:添加key-value
的实际方法{% fullimage /images/HashMap-put流程.png,HashMap-put流程,HashMap-put流程%}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
//记录当前的hash表
Node<K,V>[] tab;
//记录当前的链表节点
Node<K,V> p;
//n 记录hash表长度 i 记录当前操作的index
int n, i;
//tab 为空则创建
if ((tab = table) == null || (n = tab.length) == 0)
// 初始化hash表,并把初始化后的hash表长度值赋值给n
n = (tab = resize()).length;
//通过hash & (length -1 ) 确定最后的元素存储位置
if ((p = tab[i = (n - 1) & hash]) == null)
//计算得出位置没有元素存在,则新建节点
tab[i] = newNode(hash, key, value, null);
else {
//当前位置已存在节点,可能是修改或者发生了Hash冲突
Node<K,V> e;
K k;
//得到的Hash值相同 且 定义的key也相同 可以判定为修改操作
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
//将结果赋值给 e
e = p;
// 当前节点是树节点
else if (p instanceof TreeNode)
//往红黑树结构中 插入新节点或者更新对应节点 如果是新增节点返回值为 null
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 当前节点为链表节点
else {
for (int binCount = 0; ; ++binCount) {
//遍历链表到尾端也没有找到对应key值相同节点
if ((e = p.next) == null) {
//向尾端插入新节点
p.next = newNode(hash, key, value, null);
//如果链表长度大于 阈值,就会转换成红黑树结构
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果链表中存在key相同且hash值相同的节点,则更新对应值
if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 发现对应的key,直接用新的value替换旧value,并返回旧value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//默认空实现,但是 LinkedHashMap有 实现该方法
afterNodeAccess(e);
return oldValue;
}
++modCount;
//当前存储的键值对大于 阈值 则进行扩容操作。
if (++size > threshold)
resize();
afterNodeInsertion(evict);
//证明该操作为新增操作
return null;
}
}总结流程:
- 先判断
Node<K,V>[] table
是否为空或者null,是则执行resize()
进行扩容 - 根据插入的键值
key
的hash值
,通过(length-1) & hash值
得到需要的存储位置index
,如果该位置上没有数据,则直接新建节点插入该位置。 - 如果存储位置已有元素存在,就需要判断
index
上的元素的hash值和key
是否和当前要操作的一致,一致则判定为修改操作
,覆盖原元素的value即可 - 当前存储位置既有元素,并且
key
也不一致,则判定该位置发生了hash冲突
。接下来去判断当前头节点是否为树节点(红黑树),如果是就以红黑树的方式插入或修改节点。 - 如果头节点不是树节点,则为默认的链表节点,将新增节点直接插入至链表的尾端,然后继续判断当前链表的长度是否大于
TREEIFY_THRESHOLD-1
,大于则转化为红黑树
。遍历过程中发现key
已经存在,则直接覆盖value
。 - 插入成功后,在判断当前存储的键值对数量是否大于
threshold阈值
,大于则触发扩容resize()
操作。
- 先判断
HashMap扩容 - resize()
为什么需要扩容
当需要存储的数据量大于HashMap的初始容量时,就会造成部分数据出现在链表或红黑树上,性能比直接通过数组下表查询数据差很多,就需要扩容来减少此类数据,提供查询性能。
如何触发扩容
初始化哈希表
。上文分析put()
时看到,如果哈希表为null或空,就会触发扩容进行哈希表初始化。当前数组容量过小,需要进行扩容
。HashMap存储的键值对大于threshold
时,会触发扩容。
源码解析
1 |
|
根据源码发现,扩容机制会在原基础上扩大两倍的容量进行存储。扩容后就会把原先在链表以及红黑树上的数据,重新分配到新的数组上去。
由于我们使用的2次幂的扩展(每次扩容为原大小的2倍),所以元素在扩容后数组的位置要不在原位置(index
),要不就在原位置加上扩容前的数组长度(index + olcCap
)。
简单的描述下,在链表上的数据如何进行扩容处理:
- 遍历旧表,如果元素的next为空(
node.nect == null
),直接取余后放入新数组 - 元素后面接了一个链表,那么需要新建两条链表,
hi链和lo链
- 开始遍历链表,计算每个元素的
hash值 & oldcap
的值,如果为0则插入lo链末端
,不为0则插入hi链末端
- 遍历完成后,将两条链的头节点放入新数组中。
iohead
放入原来的位置,hihead
放入原位置加上oldcap
处。
扩容后的元素移动方式就是要不在原位置,要不就是原位置加上旧容量值的位置。
HashMap获取数据 - get()
从HashMap获取数据
1 |
|
get()
源码
1 |
|
总结流程:
- 先调用
key.hashcode ^ (h>>>16)
计算出key
的hash值
- 根据计算出的
hash值
,通过(length-1) & hash值
计算出存储位置table[i]
,判断位置上是否有元素存在 - 存储位置上没有元素存在,则直接返回null。
- 存储位置上存在元素,首先比较头节点(
头节点在数组上
),如果头节点的key hash值
和要获取key hash值
相同并且first.key == key
,则返回该位置的头节点。 - 头节点元素不是要找的元素,就需要判定头节点的结构
- 头节点结构为 红黑树 (
first instanceof TreeNode
),按照红黑树的方式遍历查找节点,有就返回,没有返回null - 头节点结构为 链表(
first instanceof Node
),遍历单链表,逐一进行比较,当链表节点的key hash值
和要获取key hash值
相同并且first.key == key
,则返回该节点;遍历结束都没找到,就返回null。
拓展
HashMap和HashTable以及HashSet的区别
HashMap
基于
AbstractMap
类,实现Map、Cloneable(被克隆)、Serializable(序列化)
接口HashMap的
key,value
都可以为null
,HashMap
遇到key == null
时,数据会放在table[0]
上- HashMap初始容量为16,负载因子默认0.75,并且容器长度一定是2次幂。扩容时,也已2倍大小进行扩容。
- HashMap是先将
key
经过key.hashcode() ^ (h>>>16)
计算出hash值
,在拿hash值
经过hash & (length -1 )
得到最终存储位置- HashMap不是线程安全,如果想线程安全,可以通过
Collections.synchronizedMap()
包裹HashMap,实质上是对HashMap的所有操作加了锁(用synchronized进行修饰)。导致运行效率下降,推荐使用ConcurrentHashMap
。
HashTable
基于
Map
接口和Dictionry
类HashTable的
key,value
不允许为null
,如果key ==null
,抛出空指针异常- HashTable初始容量为11,负载因子默认0.75,扩容时是以原容量的两倍加1进行扩容,即
newCap = (oldCap << 1)+1
- HashTable用的是除留余数法计算存储位置的.
int index = (hash & 0x7FFFFFFF) % tab.length
- HashTable是线程安全的,每个操作方法都用
synchronized
进行修饰保证同步,运行效率低,建议使用ConcurrentHashMap
替换。
HashSet
- 实现了Set接口
- 由于HashSet底层由HashMap实现,所以扩容机制与HashMap相同
- HashSet只能存储对象,无法存储键值对。利用
add(E e)
插入对象,实质使用的是HashMap.put(e,new Object())
进行操作。- HashSet和HashMap一样是线程不安全的。
HashMap非线程安全,应该如何处理多线程下操作?何时会发生线程不安全情况?
HashMap不是线程安全的,如果多个线程同时对 HashMap 进行数据更改的话,会导致数据不一致或者数据污染甚至数据丢失。
当出现线程不安全的操作时,HashMap尽可能抛出ConcurrentModificationException
异常。
- 当我们在对HashMap进行遍历时,如果在遍历期间我们对HashMap进行
put()、remove()
操作,会导致modCount
发生变化(exceptedModCount != modCount
),然后抛出ConcurrentModificationException
异常,这就是fail-fast快速失败
机制。 - 由于存在扩容机制,多线程操作HashMap时,调用
resize()
进行扩容可能会导致死循环的发生。
如果想要线程安全,还是推荐使用ConcurrentHashMap
。
使用HashMap时,使用什么对象作为key比较好?
最好选择不可变对象作为key,因为为了计算hashcode()
,就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode,就会导致无法正确的找到对象。
String和Interger
等包装类就很适合作为key,而且String
最常用。因为String
是不可变的且final
修饰(保证key的不可更改性),并且已经重写了equals()和hashcode()
方法(不容易出现hash值的计算错误)。
不可变性还有其他的优点例如线程安全
。
如何使用自定义对象作为key?
HashMap的key
可以是任何类型的对象,只要它遵守了equals()和hashCode()
的定义规则,并且当对象插入到Map之后将不再会改变了。如果这个自定义对象是不可变的,那么它已经满足了作为键的条件。
hashcode()
和equals()
都是用来对比两个对象是否相等一致。由于重写的
equals()
内部逻辑一般比较全面和复杂,效率就会比较低。利用hashCode()
进行对比,只要生成一个对应的hash值
就可以了,然后比较两者的hash值
是否相同,不同肯定不相等。比较效率较高。但是如果
hash值
相同的话,可能会有两个情况:
- 他们真的是相同对象
- 由于hash的计算过程导致可能生成相同的
hash值
。这个时候就需要用到
equals()
去进行比较。在改写
equals()
时,需要满足以下3点:
- 自反性:a.equals(a) 必须为 true
- 对称性:
a.equals(b)
为true,则b.equals(a)
必须成立- 传递性:
a.equals(b)
为true,并且b.equals(c)
也为true,那么a.equals(c)
也为true。每当需要对比的时候,首先用
hashCode()
进行比较,如果hashCode()
不一样肯定不相等,就不需要调用equals()
继续比较。如果hashCode()
相同,再调用equals()
继续比较,大大提高了效率也保证了数据的准确。
1 |
|
HashMap遍历
1 |
|
内容引用
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!