Hash表
Hash表也叫散列表,是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。存放记录的数组叫做哈希表。
在HashMap中,就是将所给的“键”通过哈希函数得到“索引”,然后把内容存在数组中,这样就形成了“键”和内容的映射关系。
在了解哈希表之前,先大概了解下其他数据结构在新增,查找等基础操作执行性能:
数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)。对应到集合实现,代表就是ArrayList。
线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)。对应的集合类是LinkedList。
二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。对应的集合类有TreeSet和TreeMap。
哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)。对应的集合类就是HashMap。
哈希表的主干是数组。要新增或查找某个元素,通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。即:
1 | 存储位置 = f(关键字) |
其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。这会涉及到哈希冲突。举个例子,比如要在哈希表中执行插入操作:
查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。
当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。哈希函数的设计至关重要,好的哈希函数会尽可能地保证计算简单和散列地址分布均匀。但是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址)、再散列函数法、链地址法。而HashMap即是采用了链地址法,也就是数组+链表的方式。
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度依然为O(1),因为最新的Entry会插入链表头部,即需要简单改变引用链即可,而对于查找操作来讲,此时就需要遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
HashMap-Jdk1.7
类定义
1 | public class HashMap<K,V> |
简介
- 继承
AbstractMap
抽象类; - 实现
Map
接口,拥有一组Map通用操作; - 实现
Cloneable
接口,可进行拷贝(浅拷贝); - 实现了
Serializable
接口,可实现序列化; - 允许键/值为空对象(null);
- 非线程安全,可通过
Collections
类的静态方法synchronizedMap
获得线程安全的HashMap
; - 不保证有序性(如插入顺序),也不保证顺序不随时间变化;
数据结构
HashMap
采用的数据结构 = 数组(主) + 单链表(副),该结构又称拉链法,具体描述如下:
当实例化一个HashMap时,系统会创建一个长度为Capacity的Entry数组,这个长度被称为容量(Capacity),在这个数组中可以存放元素的位置称之为“桶”(bucket),每个bucket都有自己的索引,系统可以根据索引快速的查找bucket中的元素。 每个bucket中存储一个元素,即一个Entry对象,但每一个Entry对象可以带一个引用变量,用于指向下一个元素,因此,在一个桶中,就有可能生成一个Entry链。 Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。 Entry是HashMap中的一个静态内部类。代码如下:
1 | /** |
所以,HashMap的整体结构如下:
重要属性
1 | // 1. 容量(capacity): HashMap中数组的长度 |
加载因子详述
加载因子过大
- 填充的元素越多,空间利用率也就越高;
- 冲突概率加大,链表变长,查找效率变小;
加载因子过小
- 冲突概率减小,链表变短,查找效率变高;
- 填充的元素越少,空间利用率越低;
使用建议
- 在查找效率&空间利用率之间寻找一种平衡,即所谓的“空间效率换时间效率” or “时间效率换空间效率”;
- 若空间内存充足,可选择“空间效率换时间效率”策略,即通过设置过小的加载因子提高查询速度,但也要考虑频繁扩容带来的性能损耗;
- 若空间内存不足,可选择“时间效率换空间效率”策略,即通过设置过大的加载因子提高空间利用率,但牺牲了查询速率;
- 加载因子可自定义修改,但非特殊情况不建议修改(默认值=0.75);
构造方法
HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值。initialCapacity默认为16,loadFactory默认为0.75。
1 | //计算Hash值时的key |
在常规构造器中,并没有马上为数组table分配内存空间(有一个入参为指定Map的构造器例外),事实上是在执行第一次put操作的时候才真正构建table数组。
put操作
源码如下:
1 | public V put(K key, V value) |
根据源码分析所作出的流程图:
inflateTable–初始化哈希表
1 | private void inflateTable(int toSize) { |
inflateTable这个方法用于为主干数组table在内存中分配存储空间,通过roundUpToPowerOf2(toSize)可以确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32。
1 | private static int roundUpToPowerOf2(int number) { |
putForNullKey
1 | private V putForNullKey(V value) { |
计算Hash值
1 | // 计算Hash值:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动) |
hash方法的最后两行是真正计算hash值的算法,计算hash码的算法被称为扰动函数,所谓的扰动函数就是把所有东西杂糅到一起,可以看到这里使用了四个向右移位运算。目的就是将h的高位值与低位值混合一下,以此增加低位值的随机性。定位数组的下标是根据hash码的低位值来确定的。key的hash码是通过hashCode方法来生成的,而一个糟糕的hashCode方法生成的hash码的低位值可能会有很大的重复。为了使得hash码在数组上映射的比较均匀,扰动函数就派上用场了,把高位值的特性糅合进低位值,增加低位值的随机性,从而使散列分布的更加松散,以此提高性能。下图举了个例子帮助理解。
计算索引值(数组下标)
1 | /** |
h&(length-1
)保证获取的index
一定在数组范围内,举个例子,默认容量16
,length-1=15
,h=18
,转换成二进制计算为:
1 | 1 0 0 1 0 |
最终计算出的index=2。有些版本的对于此处的计算会使用 取模运算,也能保证index一定在数组范围内,不过位运算对计算机来说,性能更高一些(HashMap中有大量位运算)
所以最终存储位置的确定流程是这样的:
addEntry–添加键值对
1 | /** |
流程如下(键值对的添加方式–>单链表的头插法):
resize–数组扩容
1 | /** |
流程如下:
transfer–数据转移
1 | /** |
流程如下:
createEntry–添加键值对
1 | /** |
总结
向 HashMap
添加数据(成对 放入 键 - 值对)的全流程:
get操作
1 | /** |
流程如下:
其他操作
1 | /** |
扩容导致死循环分析
HashMap是线程不安全的,原因就是在多线程下并发执行put()
操作导致触发扩容resize()
,从而导致环形链表,使得在获取数据遍历链表时形成死循环。
resize()
的源码前面已经分析过了,从源码可以看出在resize()
过程中,在将旧数组上的数据转移到新数组上时,转移数据操作需要将旧链表正序遍历,然后在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况。
假设重新计算存储位置后不变,即扩容前 1->2->3,扩容后 3->2->1
,此时若(多线程)并发执行 put()
操作,一旦出现扩容情况,则 容易出现 环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop
),即 死锁的状态,如下图:
注:由于 JDK 1.8
转移数据操作 = 按旧链表的正序遍历链表、在新链表的尾部依次插入,所以不会出现链表 逆序、倒置的情况,故不容易出现环形链表的情况。但 JDK 1.8
还是线程不安全,因为 无加同步锁保护。
HashMap-Jdk1.8
数据结构
在Jdk1.8
中,优化了HashMap
的数据结构,引入红黑树
,即HashMap的数据结构 = 数组 + 链表 + 红黑树
。
引入原因
引入红黑树的目的在于提高HashMap的性能,即解决发生哈希碰撞后,链表过长导致索引效率慢的问题。具体就是利用红黑树快速增删改查的特点,将时间复杂度从原来的O(n)降到O(log(n))。
应用场景
当链表长度>8 时,将该链表转换成红黑树;
数组元素&链表结点实现类
Jdk1.8
HashMap
中的数组元素&链表结点采用Node
类实现,该类源码如下:
1 | /** |
红黑树结点实现类
1 | /** |
重要属性
1 | /** |
构造函数
1 | /** |
put操作
1 | /** |
hash–计算Hash值
1 | /** |
putVal–存放数据到哈希表
由于数据结构中加入了红黑树,所以在存放数据到哈希表中时,需进行多次数据结构的判断:数组、红黑树、链表。
1 | /** |
流程图如下:
resize–扩容
1 | /** |
流程如下:
resize()
方法中比较重要的是链表和红黑树的 rehash
操作,rehash
的实现原理如下:
在扩容的时候,一般是把长度扩为原来2倍,所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
如下图所示,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,在扩充HashMap的时候,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
这个算法很巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的结点分散到新的槽中了。
get操作
1 | /** |
流程如下:
HashMap Jdk1.7和Jdk1.8的区别
数据结构
添加数据
获取数据
扩容机制
ConcurrentHashMap-Jdk1.7
数据结构
ConcurrentHashMap
采用分段锁的设计Segment
+ HashEntry
的方式进行实现,采用table数组
+单向链表
的数据结构,结构如下:
重要属性
1 | //默认ConcurrentHashMap的大小 |
重要对象
Segment
Segment
类继承于 ReentrantLock
类,从而使得 Segment
对象能充当锁的角色,并且是一个可重入锁。每个 Segment
对象维护其包含的若干个桶(即HashEntry[]
)。
1 | static final class Segment<K,V> extends ReentrantLock implements Serializable { |
HashEntry
HashEntry
封装了key-value
对,是一个单向链表结构,每个HashEntry
结点都维护着next HashEntry
结点的引用。
1 | static final class HashEntry<K,V> { |
初始化
1 | public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) { |
根据用户创建
new ConcurrentHashMap(....)
时,传递的值或者默认值进行ConcurrentHashMap
的初始化。
创建一个Segments[]
数组,最大数组长度是16,不可以扩容。Segments[i]
的默认大小为2,负载因子为0.75,得出初始阈值为1.5,也就插入第一个元素不会触发扩容,插入第二个进行一次扩容。
初始化了Segments[0]
,其他位置还是null。
put操作
1 | public V put(K key, V value) { |
根据
hash
值获取分段锁segment
的内存地址,如果获取到的segment
为null
,则初始化。否则就是放值。整体流程如下图:
ensureSegment-初始化segment
1 | private Segment<K,V> ensureSegment(int k) { |
根据
k
的hash
值,获取segment
,如何获取不到则就初始化一个和Segment[0]
一样大小的segment
。并通过CAS
操作,初始化到Segments[]
中。
put-放值
1 | final V put(K key, int hash, V value, boolean onlyIfAbsent) { |
首先是尝试获取
segment
的锁,获取到向下执行,获取不到就通过自旋操作去获取锁。
拿到锁之后,找到k
所在的HashEntry
数组的下标,然后获取头结点。向下遍历头结点,查找到就更新(默认),没查找到就新建一个HashEntry
,通过头插法放入HashEntry
数组,最后更新HashEntry
。
scanAndLockForPut-获取锁
1 | private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) { |
拿到k所在的segment的HashEntry的头结点(想想segment中的数据结构),首先尝试获取segment的锁。
获取失败
- 如果头结点为
null
,则将传进来的key-value
新建一个HashEntry
,同时设置retries
为0,从而再次去尝试获取segment
的锁; - 如果头结点不为
null
,并且头结点的key == 传进来的key,设置retries
为0,从而再次去尝试获取segment
的锁; - 如果头结点不为
null
,并且头结点的key != 传进来的key 则获取头结点的下一个结点,再次尝试获取segment
的锁;
- 如果头结点为
- 如果
retries > MAX_SCAN_RETRIES
则调用reentrantlock
的lock
方法,将当前操作放入到lock
队列中跳出while
循环; - 如果
retries
为偶数,则再次获取对应segment
的头结点,判断是否有变化,有就重新获取头结点;
获取成功:直接返回null
rehash-扩容
1 | private void rehash(HashEntry<K,V> node) {//node为待新加入的结点 |
原
HashEntry
数组扩大一倍,然后遍历原HashEntry
数组,找到某个结点lastRun
,从这个结点开始往下,都会放到新HashEntry数组的某个槽下面。
接下来就是遍历剩下的数据,然后采用头插法,放到新数组中。最后就是将待新加入的结点,放到新数组中。
get操作
1 | public V get(Object key) { |
计算 hash 值,找到 segment 数组中的具体位置,使用Unsafe获取对应的Segment,根据 hash 找到数组中具体的位置,从链表头开始遍历整个链表(因为Hash可能会有碰撞,所以用一个链表保存),如果找到对应的key,则返回对应的value值,否则返回null。
注:get操作不需要锁,由于其中涉及到的共享变量都使用volatile修饰,volatile可以保证内存可见性,所以不会读取到过期数据。
remove操作
1 | public V remove(Object key) { |
找到
key
所在HashEntry[]
数组的下标,然后遍历链表,找到结点。将当前结点的上一个结点的next
设置成当前结点的下一个结点。
size操作
1 | public int size() { |
- 先尝试
RETRIES_BEFORE_LOCK
次( 即2次 )不加锁的情况下,将segment[]
数组中每个segment
的count
累加,同时也会将每个segment
的modCount
进行累加。如果两次不加锁的操作后,modCountSum
值是一样的,这说明在这两次累加segmentcount
的过程中ConcurrentHashMap
没有发生结构性变化,那么就直接返回累加的count
值;- 如果在两次累加
segment
的count
操作期间ConcurrentHashMap
发生了结构性改变,则会通过将所有的segment
都加锁,然后重新进行count
的累加操作。在完成count
的累加操作后,释放所有的锁。最后返回累加的count
值。- 如果累加的
count
值大于了Integer.MAX_VALUE
,则返回Integer.MAX_VALUE
。
ConcurrentHashMap-Jdk1.8
数据结构
Java7
中ConcurrentHashMap
是采用了数组+链表
的数据结构。要知道链表的查找效率是低下的,其平均的查找时间是O(logn)
,其中n是链表的长度。当链表较长时,查找可能会成为哈希表的性能瓶颈。
针对这个问题,Java8
中对ConcurrentHashMap
的数据结构进行了优化,采用了数组+链表+红黑树
的数据结构。当数组中某个链表的长度超过一定的阈值之后,会将其转换成红黑树,以提高查找效率。当红黑树结点数少于一定阈值后,还能降红黑树逆退化成链表。下图是Java8
ConcurrentHashMap
的数据结构示意图:
Java8
中取消了Java7
中的分段锁设计,而采用了 CAS + synchronized
来保证并发安全性。
重要属性
1 | // 最大容量 |
重要类
Node-构成元素的基本类
最核心的内部类,它包装了
key-value
键值对,所有插入ConcurrentHashMap
的数据都包装在这里面。它与HashMap
中的定义很相似,但是有一些差别它对value
和next
属性设置了volatile
同步锁(与JDK7
的Segment
相同),它不允许调用setValue
方法直接改变Node
的value
域,它增加了find
方法辅助map.get()
方法。
1 | static class Node<K,V> implements Map.Entry<K,V> { |
TreeNode-树结点类
树节点类,另外一个核心的数据结构。当链表长度过长的时候,会转换为
TreeNode
。
但是与HashMap
不相同的是,它并不是直接转换为红黑树,而是把这些结点包装成TreeNode
放在TreeBin
对象中,由TreeBin
完成对红黑树的包装。
而且TreeNode
在ConcurrentHashMap
继承自Node
类,而并非HashMap
中继承成自LinkedHashMap.Entry<K,V>
类,也就是说TreeNode
带有next
指针,这样做的目的是方便基于TreeBin
的访问。
1 | static final class TreeNode<K,V> extends Node<K,V> { |
TreeBin-树的头结点
这个类并不负责包装用户的
key
、value
信息,而是包装的很多TreeNode
节点。它代替了TreeNode
的根节点,也就是说在实际的ConcurrentHashMap
“数组”中,存放的是TreeBin
对象,而不是TreeNode
对象,这是与HashMap
的区别。另外这个类还带有了读写锁。在构造TreeBin
节点时,仅仅指定了它的hash
值为TREEBIN
常量,这也就是个标识。
1 | static final class TreeBin<K,V> extends Node<K,V> { |
ForwardingNode-转移的时候放在头部的结点
一个用于连接两个
table
的节点类。它包含一个nextTable
指针,用于指向下一张表。而且这个节点的key
value
next
指针全部为null
,它的hash
值为-1
. 这里面定义的find
的方法是从nextTable
里进行查询节点,而不是以自身为头节点进行查找。其中存储nextTable
的引用。只有table发生扩容的时候,ForwardingNode
才会发挥作用,作为一个占位符放在table
中表示当前节点为null
或则已经被移动。
1 | static final class ForwardingNode<K,V> extends Node<K,V> { |
3个原子操作
在ConcurrentHashMap
中使用了unSafe
方法,通过直接操作内存的方式来保证并发处理的安全性,使用的是硬件的安全机制。
1 | /* |
构造器
1 | //空的构造 |
在任何一个构造方法中,都没有对存储
Map
元素Node
的table
变量进行初始化。而是在第一次put
操作的时候在进行初始化。
put操作
1 | /* |
initTable-数组初始化
1 | /** |
主要就是初始化一个合适大小的数组,然后会设置
sizeCtl
。初始化方法中的并发问题是通过对sizeCtl
进行一个CAS
操作来控制的。
treeifyBin-链表转红黑树
1 | private final void treeifyBin(Node<K,V>[] tab, int index) { |
treeifyBin
不一定就会进行红黑树转换,也可能是仅仅做数组扩容。
tryPresize-扩容
1 | private final void tryPresize(int size) { |
这个方法的核心在于
sizeCtl
值的操作,首先将其设置为一个负数,然后执行transfer(tab, null)
,再下一个循环将sizeCtl
加 1,并执行transfer(tab, nt)
,之后可能是继续sizeCtl
加 1,并执行transfer(tab, nt)
。所以,可能的操作就是执行 1 次transfer(tab, null)
+多次transfer(tab, nt)
,这里怎么结束循环的需要看完transfer
源码才清楚。
transfer-数据迁移
1 | /** |
此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null。
先理解下并发操作的机制。原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了,而 Doug Lea 使用了一个 stride,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex 的作用。
第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程,这个读者应该能理解吧。其实就是将一个大的迁移任务分为了一个个任务包。
putTreeVal-插入树结点
1 | final TreeNode<K,V> putTreeVal(int h, K k, V v) { |
get操作
1 | public V get(Object key) { |
- 计算
hash
值; - 根据
hash
值找到数组对应位置:(n - 1) & h
; - 根据该位置处结点性质进行相应查找
- 如果该位置为
null
,那么直接返回null
就可以了; - 如果该位置处的节点刚好就是我们需要的,返回该节点的值即可;
- 如果该位置节点的
hash
值小于 0,说明正在扩容,或者是红黑树; - 如果以上 3 条都不满足,那就是链表,进行遍历比对即可;
- 如果该位置为