应用JMH测试大型HashMap的性能

简介:

PolarDB初赛进展

写这篇是因为PolarDB比赛很重要的一点是控制内存。C++只有2G,Java也只有3G,而6400W的键值对,即使只是Long类型,也需要16 * 64 * 10e6 ≈ 1G的内存,这还不包括其他对象引用的相关开销,所以内存控制在这里是非常重要的,因为稍不小心就会被CGroup无情地kill掉。因此在比赛开始没多久的时候我就研究了一下使用怎样的HashMap可以达到内存最简的状况。在这个过程中,顺便使用了JMH来分析了一下几个侯选库的性能。因为初赛相对来说比较简单,而且HashMap实际上在复赛时候的Range操作上没有发挥余地,所以我决定将这篇写下来分享给大家,希望能帮助更多对比赛有兴趣的同学找到一个比较好的入手点。

之前的初赛简单思路可以看这里。

侯选的集合库

我们能第一时间想到的最朴素最直接的候选者就是Java自带的HashMap了,这是我们平时使用最多也是最熟悉的实现。只不过在这里因为性能和内存消耗的原因,它稍微有点不合适。其实市面上有很多其他优秀的集合库实现的,我在这里大致列一下我这边会测试的几个:

  1. FastUtil: 一个意大利的计算机博士开发的集合库。

  2. Eclipse Collections: 由高盛开发的集合库,后来捐给了eclipse基金会,成为了eclipse的项目.

  3. HPPC: 专门为原始类型设计的集合库。

  4. Koloboke: 又一位大神的作品,目标是低内存高性能。

  5. Trove: 挂在bitbucket上面的一个开源项目。

因为是为了比赛而接触的这些库,所以我只按照比赛场景给他们做了测试。我们会预生成6400W对8字节的Key,和8字节的长整型Value,之后会将这些key全部写入各自的HashMap中去,然后再从中读取出来,并与暂存的Value作比较,判断正确性。整个的测试过程是交给JMH来做的。下面介绍一下JMH工具。

JMH简介

JMH是由OpenJDK开发的,用来构建、运行和分析Java或其他Jvm语言所写的程序的基准测试框架。它可以帮助我们自动构建和运行基准测试,并且汇总得到结果。现在一般Java世界里面的主流Benchmark就是应用的JMH。

Scala这边,我们所熟悉的Ktoso大佬包了一个sbt-jmh插件,使得我们可以方便地利用SBT来运行JMH测试。要使用sbt-jmh插件,首先,在plugins.sbt文件里面添加插件:

1// project/plugins.sbt
2addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.3.4")

之后,在项目中的模块定义中,使用它:

1// build.sbt
2enablePlugins(JmhPlugin)

然后,我们就可以在sbt的console下,执行如下命令,来运行jmh测试了:

1jmh:run -i 3 -wi 3 -f1 -t1 .*FalseSharing.*

上面的参数解释下来,就是要求项目对符合正则表达式.*FalseSharing.*的基准测试,运行3次,运行之前要进行3次预热,只需要跑一遍,使用一个线程。

好,介绍结束,我们接下来看一下我们如何来编写程序测试各种Map。

HashMap测试代码

首先,我们先创建一个类,如下:

1@State(Scope.Benchmark)
2@OutputTimeUnit(TimeUnit.SECONDS)
3@BenchmarkMode(Array(Mode.Throughput))
4class jmh.HashMapBenchmark {
5
6}

JMH使用注解来标记的基准测试。上面三个注解的选项的意思分别是:

  1. State表明可以在类里面创建成员变量,供所有测试复用,复用的范围是在Benchmark当中;

  2. OutputTimeUnit表示输出Benchmark结果的时候,计时单位是TimeUnit.SECONDS

  3. BenchmarkMode,Benchmark的模式是测试吞吐率。

为了做基准测试,我们首先创建一个6400W大小的列表,列表的元素是一个二元组,都是Long:

1 val random = new Random(42)
2
3 val testSet: List[(Long, Long)] = List.range(0, 64000000).map { _ =>
4 val key = random.nextLong()
5 val value = random.nextLong()
6 key -> value
7 }

这里有个关于比赛的小技巧,由于Key都是8字节的,所以其实每个Key都很容易转化成Long类型的。所以我们在测试里面也只测试对于Long类型的写入性能,以Java的HashMap为例:

 1 @Benchmark
2 @OperationsPerInvocation(OperationsPerInvocation)
3 def testHashMap() = {
4 val hashMap = new util.HashMap[Long, Long](64000000)
5 testSet.foreach { case (k, v) =>
6 hashMap.put(k, v)
7 }
8 testSet.foreach { case (k, v) =>
9 val readValue = hashMap.get(k)
10 assert(readValue == v)
11 }
12 }

@Benchmark表示这是一个要运行的基准测试。@OperationsPerInvocation注解会接收一个数值,表示这个测试的方法运行了多少次。在我们这边是OperationPerInvocation次。注意,前面的变量在jmh.HashMapBenchmark的伴生对象中定义:

1object jmh.HashMapBenchmark {
2 val OperationsPerInvocation = 64000000 * 2
3}

然后,我们就能够启动sbt,输入前面提到的jmh:run命令了。我们先跑一波看看:

1jmh:run -i 3 -wi 3 -f1 -t1 .*HashMap.*

跑起来以后我感觉我错了,电脑风扇在狂转,而且预热半天都跑不完。jstat看一下gc情况试试先,发现100多秒都是FGC。。

1S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
2931840.0 931840.0 0.0 0.0 932352.0 932352.0 5592576.0 5592545.4 9856.0 9391.3 1408.0 1316.6 12 36.611 9 145.669 182.280
3931840.0 931840.0 0.0 0.0 932352.0 932352.0 5592576.0 5592545.4 9856.0 9391.3 1408.0 1316.6 12 36.611 9 145.669 182.280

果断杀掉,加上jvm参数以后再测试:

1jmh:run -i 3 -wi 3 -f1 -t1 --jvmArgs "-Xmx10g" .*HashMap.*

10G也是慢,跑太久了,不乐意跑了,果断放弃。我们直接来看其他的实现。

这里还要说一下,因为内存有要求,所以我们需要同时打印一下HashMap的内存大小。我所使用的是网上找到的一个应该是从Spark代码中抠出来的一个实现,速度快,估值准。只需要在build.sbt中如下引入即可。

1libraryDependencies += "com.madhukaraphatak" %% "java-sizeof" % "0.1"

主要代码编写

因为其实这里的hashmap的库使用其实大同小异,为了避免重复,所以我利用Scala的一些特性来进行代码编写。首先我定义了一个特质为LongLongOp:

1trait LongLongOp {
2 def put(key:Long, value:Long):Long
3 def get(key:Long):Long
4}

之后,我们写一个遍历testSet的函数:

1 def testSetTraverse(hashMap:LongLongOp) = {
2 testSet.foreach { case (k, v) =>
3 hashMap.put(k, v)
4 }
5 testSet.foreach { case (k, v) =>
6 val readValue = hashMap.get(k)
7 assert(readValue == v)
8 }
9 }

然后,我们利用特质可以混入的特性,在生成HashMap的时候,混入LongLongOp,然后交由testSetTraverse执行。这样我们每次只需要新建HashMap即可了。例如,FastUtil的测试如下:

1 @Benchmark
2 @OperationsPerInvocation(OperationsPerInvocation)
3 def testFastUtil() = {
4 val map = new Long2LongArrayMap(MapSize) with LongLongOp
5 testSetTraverse(map)
6 printlnObjectSize("fastutil Long2LongArrayMap", map)
7 }

其中printLnObjectSize是用来打印map占用内存数量的:

1 def printlnObjectSize(message:String, obj: AnyRef) = {
2 val size = SizeEstimator.estimate(obj)
3 val sizeInGB = size.toDouble / 1024 / 1024 / 1024
4 println(s"$message size is ${sizeInGB}GB")
5 }

之后,依次使用Eclipse Collections, Hppc, Koloboke和Trove,就完成了我们的Benchmark编写:

 1import java.util.concurrent.TimeUnit
2
3import com.koloboke.collect.impl.hash.MutableLHashParallelKVLongLongMapGO
4import com.madhukaraphatak.sizeof.SizeEstimator
5import gnu.trove.map.hash.TLongLongHashMap
6import org.openjdk.jmh.annotations._
7
8import scala.util.Random
9
10object HashMapBenchmark {
11 final val OperationsPerInvocation = 64000000 * 2
12}
13
14trait LongLongOp {
15 def put(key: Long, value: Long): Long
16
17 def get(key: Long): Long
18}
19
20@State(Scope.Benchmark)
21@OutputTimeUnit(TimeUnit.SECONDS)
22@BenchmarkMode(Array(Mode.Throughput))
23class HashMapBenchmark {
24
25 import HashMapBenchmark._
26
27 val random = new Random(42)
28 val MapSize = 64000000
29
30 val testSet: List[(Long, Long)] = List.range(0, MapSize).map { _ =>
31 val key = random.nextLong()
32 val value = random.nextLong()
33 key -> value
34 }
35
36 def testSetTraverse(hashMap: LongLongOp) = {
37 testSet.foreach { case (k, v) =>
38 hashMap.put(k, v)
39 }
40 testSet.foreach { case (k, v) =>
41 val readValue = hashMap.get(k)
42 assert(readValue == v)
43 }
44 }
45
46 def printlnObjectSize(message: String, obj: AnyRef) = {
47 val size = SizeEstimator.estimate(obj)
48 val sizeInGB = size.toDouble / 1024 / 1024 / 1024
49 println(s"$message size is ${sizeInGB}GB")
50 }
51
52 @Benchmark
53 @OperationsPerInvocation(OperationsPerInvocation)
54 def testEclipseCollection() = {
55
56 import org.eclipse.collections.impl.map.mutable.primitive.LongLongHashMap
57
58 val map = new LongLongHashMap(MapSize)
59 testSet.foreach { case (k, v) =>
60 map.put(k, v)
61 }
62 testSet.foreach { case (k, v) =>
63 val readValue = map.get(k)
64 assert(readValue == v)
65 }
66 printlnObjectSize("Eclipse LongLongHashMap", map)
67 }
68
69 @Benchmark
70 @OperationsPerInvocation(OperationsPerInvocation)
71 def testHppc() = {
72
73 import com.carrotsearch.hppc.LongLongHashMap
74
75 val map = new LongLongHashMap(MapSize, 0.99) with LongLongOp
76 testSetTraverse(map)
77 printlnObjectSize("hppc LongLongHashMap", map)
78 }
79
80 @Benchmark
81 @OperationsPerInvocation(OperationsPerInvocation)
82 def testKoloboke() = {
83 val map = new MutableLHashParallelKVLongLongMapGO() with LongLongOp
84 testSetTraverse(map)
85 printlnObjectSize("Koloboke", map)
86 }
87
88 @Benchmark
89 @OperationsPerInvocation(OperationsPerInvocation)
90 def testTrove() = {
91 val map = new TLongLongHashMap(MapSize, 1.0f) with LongLongOp
92 testSetTraverse(map)
93 printlnObjectSize("Trove LongLongMap", map)
94 }
95}

其中Eclipse collection的put方法的返回值是void,与其他集合不一样,所以只能单独为它写一个测试方法。

结果

运行的过程中,Koloboke报一个诡异的空指针错误,所以没有通过测试;FastUtils在这个量级好像有点慢,不乐意等所以最终没有把它加入测试。最终我们得到如下的结果列表:

5d361c29ca0cc06caf49bb861cc5cef0fe34fe68

综合内存使用以及性能,我个人觉得在此次比赛初赛中,也许HPPC是个比较好的选择。

所以,初赛使用Java的HashMap实现的小伙伴,是不是应该赶紧思考一下换一下内存索引的结构,来避免OOM呢?


原文发布时间为: 2018-11-07
本文作者:Kirito的技术分享
本文来自云栖社区合作伙伴“Kirito的技术分享”,了解相关信息可以关注“Kirito的技术分享”。

相关文章
|
12月前
|
监控 安全 Shell
管道符在渗透测试与网络安全中的全面应用指南
管道符是渗透测试与网络安全中的关键工具,既可用于高效系统管理,也可能被攻击者利用实施命令注入、权限提升、数据外泄等攻击。本文全面解析管道符的基础原理、实战应用与防御策略,涵盖Windows与Linux系统差异、攻击技术示例及检测手段,帮助安全人员掌握其利用方式与防护措施,提升系统安全性。
504 6
|
11月前
|
人工智能 数据可视化 测试技术
AI 时代 API 自动化测试实战:Postman 断言的核心技巧与实战应用
AI 时代 API 自动化测试实战:Postman 断言的核心技巧与实战应用
1274 11
|
11月前
|
测试技术 UED 开发者
性能测试报告-用于项目的性能验证、性能调优、发现性能缺陷等应用场景
性能测试报告用于评估系统性能、稳定性和安全性,涵盖测试环境、方法、指标分析及缺陷优化建议,是保障软件质量与用户体验的关键文档。
|
安全 测试技术 Linux
Flawnter 5.9.1 (macOS, Linux, Windows) - 应用程序安全测试软件
Flawnter 5.9.1 (macOS, Linux, Windows) - 应用程序安全测试软件
462 2
Flawnter 5.9.1 (macOS, Linux, Windows) - 应用程序安全测试软件
|
测试技术 数据库 Python
解释测试中setup和teardown函数的应用。
总结起来,`setup`和 `teardown`函数就像扔宴会的主人,他们保障了宴会的流畅进行。他们是准备环境和清理现场的重要工作人员,他们的工作直接影响着我们的测试效率和质量。我们可以把 `setup`和 `teardown`想象成隐藏在幕后,默默为我们服务的工作者,他们做着我们需要但是往往忽视的工作。所以,下次当你写测试的时候,别忘了给你的 `setup`和 `teardown`留出足够的位置,因为他们的作用可能是你成功的保证。
330 14
|
机器学习/深度学习 存储 分布式计算
Java 大视界 --Java 大数据机器学习模型在金融风险压力测试中的应用与验证(211)
本文探讨了Java大数据与机器学习模型在金融风险压力测试中的创新应用。通过多源数据采集、模型构建与优化,结合随机森林、LSTM等算法,实现信用风险动态评估、市场极端场景模拟与操作风险预警。案例分析展示了花旗银行与蚂蚁集团的智能风控实践,验证了技术在提升风险识别效率与降低金融风险损失方面的显著成效。
|
人工智能 IDE 测试技术
Browser-Use在UI自动化测试中的应用
Browser-Use是一款浏览器自动化工具,具备视觉与HTML解析、多标签管理、操作记录与复现、自定义操作、自我纠正及并行执行等功能,助力AI智能体高效完成网页任务。
1542 0
|
存储 5G 测试技术
时钟同步测试校验仪的应用介绍
时间同步测试仪是一种高精度、高可靠性的设备,用于测量和评估时间同步系统的性能。它广泛应用于电力系统(如电网调度、继电保护)、通信网络(如5G基站、光传输网络)、铁路交通(如列车运行控制、信号系统)、工业自动化(如生产线、控制系统)以及科学研究(如天文观测、粒子物理实验)等领域。其功能包括高精度时间测量、多信号接口支持、自动测量与分析、数据存储导出及性能评估输出,确保各领域设备间的时间同步精度与稳定性,保障系统高效运行。
|
Java
让星星⭐月亮告诉你,HashMap中保证红黑树根节点一定是对应链表头节点moveRootToFront()方法源码解读
当红黑树的根节点不是其对应链表的头节点时,通过调整指针的方式将其移动至链表头部。具体步骤包括:从链表中移除根节点,更新根节点及其前后节点的指针,确保根节点成为新的头节点,并保持链表结构的完整性。此过程在Java的`HashMap$TreeNode.moveRootToFront()`方法中实现,确保了高效的数据访问与管理。
224 2
|
Java 索引
让星星⭐月亮告诉你,HashMap之往红黑树添加元素-putTreeVal方法源码解读
本文详细解析了Java `HashMap` 中 `putTreeVal` 方法的源码,该方法用于在红黑树中添加元素。当数组索引位置已存在红黑树类型的元素时,会调用此方法。具体步骤包括:从根节点开始遍历红黑树,找到合适位置插入新元素,调整节点指针,保持红黑树平衡,并确保根节点是链表头节点。通过源码解析,帮助读者深入理解 `HashMap` 的内部实现机制。
320 2