最近看了很多 Map 实现的源码,循迹着一篇比较各类实现性能比较的综述,发现这篇文章,正好切合我的要求。趁着作者给的测评程序跑着(然而已经跑了一个小时了(○´・д・)ノ),搜索了一下发现没有人把这么好的文章翻译一下,可能内容比较基础,但是这种追求卓越的精神感染到了我,让我想起来以前读CSAPP时的感觉,于是决定翻译一下。同时,作者 Mikhail Vorontsov 其他的文章也非常好,有很多 Java 调优技巧和介绍 Java 性能的内容。
原文地址:http://java-performance.info/implementing-world-fastest-java-int-to-int-hash-map/
- 最快的 int-to-int map 实现测评请看 我之前的文章 。
我要感谢 Sebastiano Vigna 和 Roman Leventov 和我分享了实现哈希表的智慧。一些实现的想法也来自于 Kris Kaspersky 的 “Code Optimization: Effective Memory Usage”。
这篇文章将一步步带你领略各式各样的现代哈希表实现。在文章的最后,你将获得一个可能是撰写本文时最快的 Java int-to-int 哈希表。
开放地址法
大多数现代哈希表都是基于开放地址法。这意味着什么?你的哈希表将基于 keys 数组( values 也会放到这个数组的适当位置,所以现在先忘掉他们)。在进行每个操作时,你必须先在 keys 数组中找到特定的 key 。那么如何实现呢?
首先,你需要一个起始位置。可以用任何一个哈希函数把 key 映射到[0, length - 1]的区间上。一个 key 通常通过 hashCode 方法映射为一个整数,然后再通过一个简单的哈希函数,例如 Math.abs(key.hashCode() % array.length) (记住,% 运算可能为负值)。
正如你所知道的,大量的 keys 映射到一个小的哈希表中会产生一些冲突(我们称之为哈希冲突)。寻找不同 keys 的初始位置也一样,可以通过另一个函数来解决冲突,例如 (prevIdx + 1) % array.length ,这就要求这个函数必须能覆盖到整个数组容量。当数组长度为素数时还可以通过偏移一个素数来实现这个函数。
空单元和已删除单元
理论上,现在已经可以实现你自己的哈希表了。但实际上,你需要区别空单元和已删除单元(你可以避免标志删除单元,如果在 remove 方法中做一些额外的工作,可以看看 FastUtil 的实现)。已删除单元也叫做“墓碑”。
你的 keys 可以直接填充到空单元,如果你删除一个已经存在的 key ,你需要将它标志位“已删除”状态。
看一下如下的例子:
这个 int 哈希表用如下的初始函数和再探测函数:
1 |
initial = Math.abs( key % array.length ); |
哈希表中有 keys 为1,2,3和4,但是3被删除了,所以用R来占位、
我们来看一下如何查找下面的 keys :
Key | 描述 |
---|---|
2 | 初始函数指向索引为2的单元,匹配查找的 key 2 ,所以不需要其他的查找了 |
3 | 初始函数指向索引为3的单元,这个单元被标记为“已删除”,所以我们需要应用在探测函数继续查找,直到要查找的 key 或者一个空单元:索引为 4 的单元不匹配,索引为 5 的单元是一个空单元,所以我们停止查找——查找失败 |
下面,我们看看如何添加 key = 10 : initial = key % array.length = 10 % 9 = 1 ,索引为1的单元已经被另外一个 key 占据,所以我们不能使用它。再探测索引为 2 时也是一样的,直到索引为 3 时是已删除单元,所以可以重用它,并把 key = 10 填充到其中。
去掉已删除单元
在许多情况下,如果你要将已删除单元格保留在哈希表中,那么你的哈希映射可能会降级为O(n2)复杂度。高效的哈希表用某种方式去掉已移除单元格。因此所有其他方法都需要区分2种单元状态:空闲或使用。 同时,remove 方法通常不常用,远远小于 put 方法。本文将使用FastUtil的清理逻辑。
散列加扰
采用如上的初始函数,当我们需要放置连续的 keys 时,就会造成很长的查找链。为了避免这种情况,我们需要散列加扰,打乱他们的二进制位,例如:
1 |
private static final int INT_PHI = 0x9E3779B9; |
这样,连续的 keys 就不会被防止在连续的数组单元中,继而使得查找保持在平均的长度。对于随机的 keys ,它们的分布将会更好。
现在你已经完全可以实现自己的哈希表了,我们将在本文接下来的几节实现它。
版本1:基础int-int 哈希表
我们首先实现足够简单的哈希表(一个足够有优化空间的实现)。这个实现会非常像 Trove 3.0 中的 TIntIntHashMap(我并没有抄袭它的源代码)。
它将用三个数组:一个 int[] 用来放置 keys,一个 int[] 用来放置 values 还有一个 boolean[] 用来放置“已使用”标志位。我们将为它们分配初始空间(可以是 size / fillFactor + 1)。初始函数和再探测函数如下:
1 |
initial = Math.abs( Tools.phiMix(key) % array.length); |
你可以在本文的结尾看到这个哈希表和其他哈希表的源代码。
我将对比 之前的文章 的结果.我们将它和 Koloboke 对比,本文所有的测试都是用随机 keys 集合,所有哈希表的填充因子都为 0.75 。
Map size: | 10.000 | 100.000 | 1.000.000 | 10.000.000 | 100.000.000 |
---|---|---|---|---|---|
KolobokeMap | 1867 | 2471 | 3129 | 7546 | 11191 |
IntIntMap1 | 2768 | 3671 | 6105 | 12313 | 16073 |
太棒了!第一个没有优化的尝试只比 Koloboke 慢两倍。
版本2:避免昂贵的 % 运算——数组容量变为2的幂次
许多人认为整数的除法求余运算已经不再那么慢了——因为新的CPUs更好更快。但是,这是错误的。所有的整数除法运算扔非常慢,在性能严苛的代码中不应出现。
当前版本的哈希表利用 % 运算符求余,我们可以将数组的长度变为2的幂次来避免它。如下所示:
1 |
initial = Tools.phiMix( key ) & (array.length-1); |
array.length - 1 应该被缓存到实例域中,为什么它可以被当作 mask 呢?因为如果 K=2^N ,那么 X % K == X & (K - 1)。利用 & 运算可以使得结果非负,并进一步加速计算过程。
记住所有的高性能哈希表都依靠这个优化。
让我们再看一下对比结果:
【译】实现一个世界上最快的int-int mapMap size: | 10.000 | 100.000 | 1.000.000 | 10.000.000 | 100.000.000 |
---|---|---|---|---|---|
KolobokeMap | 1867 | 2471 | 3129 | 7546 | 11191 | 大专栏 r>
IntIntMap1 | 2768 | 3671 | 6105 | 12313 | 16073 |
IntIntMap2 | 2254 | 2767 | 4869 | 10543 | 16724 |
这个优化让我们跨越了一大步!接下来还有很长的路要走。
版本3:摆脱 m-used 数组
之前版本的哈希表用三个不同的数组来存储数据。这意味着需要获取三块不同的内存区域,会可能导致 CPU 缓存未命中。高性能的代码应该最小化缓存未命中的数量,最直观的优化是我们可以去掉 m-used 数组并用更聪明的方法标识单元。
问题是我们正在实现一个 int-to-int 哈希表,所以我们会用到任何 int来作为 key (如果一些key N 被保留并且不能被使用是多么可悲)。这意味着我们需要额外的标识存储,对么?是的,但关键是我们可以用 O(1) 来代替 O(n)!
这个想法是选用一个特殊的 key 来标识空单元。有如下两个策略(我将会使用第一个):
1.利用额外一个空间存储空单元标识的 value。还需要一个标志位来标识这个key是否被使用,每个哈希表方法开头都需要对其进行校验并作出不同的逻辑动作。
2.选择一个随机空单元 key 。如果你正要往哈希表中插入空单元 key ,选择一个新的随机空单元 key ,这个 key 是从未在哈希表中出现过的,然后覆盖之前所有的空单元。 Koloboke 是唯一用这种策略的。
顺便提一句,选择 Objects keys 数组的空单元值时将会很简单:
1 |
private static final Object FREE_KEY = new Object(); |
这个 key 将不能被其他类访问,所以将不可能会被传入到你的哈希表中。有些聪明的家伙可能记得反射并题型说,哈希表的 keys 必须重写 equals 和 hashcode ,但对这个私有的对象却不是这种情况。
正如我上面提到的,我们的实现将采用硬编码(0, 一个最方便比较的常量)并存储他对应的 value 在一个实例域中。
1 |
private static final int FREE_KEY = 0; |
我们再来比较一下测试性能:
Map size: | 10.000 | 100.000 | 1.000.000 | 10.000.000 | 100.000.000 |
---|---|---|---|---|---|
KolobokeMap | 1867 | 2471 | 3129 | 7546 | 11191 |
IntIntMap1 | 2768 | 3671 | 6105 | 12313 | 16073 |
IntIntMap2 | 2254 | 2767 | 4869 | 10543 | 16724 |
IntIntMap3 | 2050 | 2269 | 3548 | 9074 | 13750 |
正如你看到的,哈希表随着大小增加的影响(你的哈希表越大,对CPU缓存命中的帮助就越小)。尽管,我们离 Koloboke 还很远,但是它已经在我之前的文章中排名第三,紧随 Koloboke 和 FastUtil之后。
版本 4 和 4a :用一个数组代替 keys 和 values 数组
这一步遵循前一步骤的方向——现在我们要使用单个数组来存储键和值。这将使我们能够以非常低的成本访问/修改 values ,因为它们将位于 key 的旁边。
这里有两种可行的实现:
1.用一个 long[] ,一个 key 和 一个 value 将共享一个 long 。这种方法的有效性仅限于某些类型的 key 和 value。
2.用一个 int[] ,keys 和 values 将间隔的分布。这个方法缺点就是容量只有10亿,我相信对于大多数场景不是问题。
这两种情况之间的区别在于需要使用位算术/类型转换来从 long 数组中提取 key 和 value。我的测试显示这些操作对哈希表的性能有显着的负面影响。不过我已经将 long [](IntIntMap4)和int [](IntIntMap4a)版本都包含到本文中。
小优化点
两个版本都会非常快,但你需要更多优化才能成为最快的版本。你应该了解一个hashmap,它的基本操作具有O(1)复杂度,这一点很重要,只要你不太过于追求填充因子。 这实际上意味着你必须计算散列命中路径上的指示(您检查的第一个单元,如果是空的或包含请求的 key)。优化哈希碰撞循环也很重要,但是(我重复这一点),您必须非常小心哈希命中路径,因为大多数操作最终都会以哈希命中结束。
考虑到这一点,您可能想要内联一些辅助方法,尤其是那些可以在内联时为您节省指令的方法。 例如,看一下上一个版本的get方法:
1 |
public int get( final int key ) |
第一行的判断大多数情况都为 true ,所以可以不在这里判断。