AppendOnlyMap
是spark自己实现的HashMap,与java自身的HashMap不同,它只能添加数据,不能remove,同时支持在内存中对任务执行结果进行聚合运算,本节我们来看下这个数据结构。
整体结构
AppendOnlyMap
本质是一个HashMap,内部使用数组来存储数据,它的key-value是使用数组中相邻的两个元素进行存储的,如下图所示:
我们来看下源码中相关存储以及容量的定义:
val MAXIMUM_CAPACITY = (1 << 29)
private var capacity = nextPowerOf2(initialCapacity)
private var data = new Array[AnyRef](2 * capacity)
// 计算数据存放位置的掩码。计算mask的表达式为capacity – 1
private var mask = capacity - 1
// 用于计算data数组容量增长的阈值的负载因子。固定为0.7。
private val LOAD_FACTOR = 0.7
// data数组容量增长的阈值。
private var growThreshold = (LOAD_FACTOR * capacity).toInt
private def nextPowerOf2(n: Int): Int = {
// 获取n的中最高的一位1所代表的数字
val highBit = Integer.highestOneBit(n)
// 如果与n相同,则取n,否则左移1位,即乘以2
if (highBit == n) n else highBit << 1
}
可以看出来数组的容量是2的倍数,而且最大容量capacity
是1 << 29
,另外定义了容量增长的负载因子,固定为0.7,这是为什么呢?
- 容量是2的倍数:我们知道计算机中
&
运算速度快比%
取模运算速度要快,总容量是2的倍数时候,我们拿到key时候,对齐进行hash运算得到hash值,我们能保证元素最终的索引值肯定在capacity中,不会超出数组长度,所以可以通过执行hash % n
获取索引位置,如果是2的倍数,那么取模运算可以使用(n - 1) & hash
来替代,从而可以加速索引计算的速度,源码中定义了掩码为capacity - 1
,只需要跟hash &运算即可得到相应的index位置; - 最大容量
1<<29
:内部数据存储是使用Array
,最大容量是Int
,所以最大值是2^31 - 1
,又必须是2的倍数,所以最大值是2^30
,这里定义最大capacity
是1 << 29
,再乘以2<key-value各使用一个index>即为2^30
; - 负载因子是0.7:负载因子和当前容量大小的乘积决定了数组扩容的阈值,当负载因子较大时,数组扩容的可能性就会少,所以相对占用内存较少,但是hash冲突会相对较多,查询的时间也会增长;负载因子较少的时候,给数组扩容的可能性就高,那么内存空间占用就多,但是hash冲突相对较少,查出的时间也会减少。0.7默认值是时间和空间上的一种折中的说法。
Hash冲突
当添加元素时候,需要计算key
对应的hash值,然后寻找在capacity
内的下标值,这时候可能会遇到改下标位置处已经被填充了的情况,发生了冲突,Java原生的HashMap
是通过拉链法去解决hash冲突的,AppendOnlyMap
是通过开放地址法–线性探测的方法进行解决冲突的,线性探测间隔总是固定的,通常为1.
H+1, H+2, H+3, H+4, …, H+k
,也就是当地址冲突,不断对地址+1,不断探测进行找到没有冲突的地方即可。
扩容
当当前数组的存储元素达到capacity * LOAD_FACTOR
时候就要进行扩容操作,扩容大小设置为之前元素的2倍,然后经过一下步骤处理:
- 新建一个二倍容量的数组,并计算新的掩码;
- 对于老的数组中的每个元素,依次遍历,重新hash;
- 如果计算的位置相同,即hash冲突,进行二次探测,那么就找下一个位置<第一次加1,第二次加2,第三次加3>,以此类推;
- 替换数组,更新新的容量大小,掩码以及增长阈值。
protected def growTable() {
// 扩容的新容量是当前容量的2倍
val newCapacity = capacity * 2
// 新容量不可超过MAXIMUM_CAPACITY,即1 << 29
require(newCapacity <= MAXIMUM_CAPACITY, s"Can't contain more than ${growThreshold} elements")
// 创建一个两倍于当前容量的数组
val newData = new Array[AnyRef](2 * newCapacity)
// 计算新数组的掩码
val newMask = newCapacity - 1
var oldPos = 0
// 将老数组中的元素拷贝到新数组的指定索引位置
while (oldPos < capacity) {
if (!data(2 * oldPos).eq(null)) {
// 取得原位置上的键和值
val key = data(2 * oldPos)
val value = data(2 * oldPos + 1)
// 计算存放对应键的新索引位置,使用新的mask掩码进行rehash计算
var newPos = rehash(key.hashCode) & newMask
var i = 1
var keepGoing = true
while (keepGoing) {
// 检查新索引上是否存在键
val curKey = newData(2 * newPos)
if (curKey.eq(null)) {
// 对应新索引位置上的键为null
// 更新键和值
newData(2 * newPos) = key
newData(