开发者社区> 游客pq45jrzoavplw> 正文

Java、Rust、Go主流编程语言的哈希表比较——《我的Java打怪日记》

简介: 哈希表(HashMap、字典)是日常编程当中所经常用到的一种数据结构,程序员经常接解到的大数据Hadoop技术栈、Redis缓存数据库等等最近热度很高的技术,其实都是对键值(key-value)数据的高效存储与提取,而key-value恰恰就是哈希表中存储的元素结构,可以说Redis、HDFS这些都是哈希表的经典应用,不过笔者之前也只知道哈希表比较快,但对于具体什么场景下快,怎么用才快等等知识却一知半解,因此这里把目前的一些研究成果分享给大家。
+关注继续查看


哈希表(HashMap、字典)是日常编程当中所经常用到的一种数据结构,程序员经常接解到的大数据Hadoop技术栈、Redis缓存数据库等等最近热度很高的技术,其实都是对键值(key-value)数据的高效存储与提取,而key-value恰恰就是哈希表中存储的元素结构,可以说Redis、HDFS这些都是哈希表的经典应用,不过笔者之前也只知道哈希表比较快,但对于具体什么场景下快,怎么用才快等等知识却一知半解,因此这里把目前的一些研究成果分享给大家。


重新认识哈希表

所谓的哈希表就是通过哈希算法快速搜索查询元素的方法,比如说你要在茫茫人海当中找到一位笔名叫做beyondma的博主,但却并不知道他具体的博客地址,在这种情况下就只能在所有的博主范围内展开逐个的排查与摸索,运气差的话我可能以找遍所有n个博主的主页,才到beyondma,这也就是这种遍历查找的时间复杂度是o(n),查找的时间会随着博主的数量而线增长。

而哈希算法就是直接将beyondma这个名字进行算法处理,直接得到beyondma的博客地址信息,在哈希算法的加持下定位某一元素的时间度变成了o(1),由于哈希算法能够将key(键值本例中指beyond)和value(本例中指beyond.csdn.net)以o(1)的时间复杂度,直接对应起来,因此哈希表被人称为key-value表,存储的元素也被称为key-value对(键值对)。哈希表的查找过程特别像查字典,给出一个字并找到这个字在字典中的位置,只是哈希表在一般情况下都很快。当然哈希表也有代价:

以空间换时间:哈希算法也称为散列算法,这种叫法相对比较直观,由于哈希算法是通过计算确认存储地址的,因此首先进入到哈希表的元素并不一定存到第一个位置,存储n个键值对的哈希表往往会消耗比切片多很多的内存空间。

哈希碰撞:哈希碰撞是指不同的键值,在经过哈希计算后得到的内存地址槽位是相同的,也就是说相同的地址上要存储两个以上的键值对,一旦发生这种情况,也就是哈希碰撞了。在发生碰撞的场景下哈希表会进行退化,其中Java会在碰撞强度到达一定级别后,会使用红黑树的方式来进行哈希键值对的存储,而Go和Rust一般都是退化成为链表。

下面我们首先来详细讲讲两个哈希表的常见误用。

哈希表的误用

不要遍历哈希表!:局部快,不意味着整体快,由于哈希表提取单个元素的速度很快,因此整个遍历整个集合所需要的时间也会更短,这种看法明显是个美丽的误会。

我们后文也会具体讲到,哈希表在遍历方面的表现结果,是由计算机组成原理决定的,与Go、Rust和Java的区别不大,因此以下例子先以Go语言的代码为例来说明。

package main

import (

"fmt"

"time"

)

func main() {

testmap := make(map[int]int)

len := 1000000

//tests1ice := make([]int, len, len)

for i := 0; i < len; i++ {

testmap[i] = i + 1

}

sum := 0

now := time.Now().UnixNano()

for k, v := range testmap {

sum = sum + k + v

}

diff := time.Now().UnixNano() - now

fmt.Println("sum=", sum)

fmt.Println("diff=", diff)

// fmt PrintIn("slice=", slice)

}


可以看到使用哈希表进行遍历的话,以上代码运行的结果为:

sum= 1000000000000

diff= 29297200

成功: 进程退出代码 0.​​




而对比使用切片遍历的代码如下:

package main

import (

"fmt"

"time"

)

func main() {

//testmap := make(map[int]int)

len := 1000000

tests1ice := make([]int, len, len)

for i := 0; i < len; i++ {

tests1ice[i] = i + 1

}

sum := 0

now := time.Now().UnixNano()

for k, v := range tests1ice {

sum = sum + k + v

}

diff := time.Now().UnixNano() - now

fmt.Println("sum=", sum)

fmt.Println("diff=", diff)

// fmt PrintIn("slice=", slice)

}



以上代码运行结果为:

sum= 1000000000000

diff= 1953900

成功: 进程退出代码 0.​​



可以看到同样长度的集合遍历性能表现,切片的耗时只有哈希表的5%左右,两者几乎相差两个数量级。

数据访问局部性原理的制约:局部性原理可能是计算机基本原理中威力最强的基本定理之一,也是程序员在编程过程中必须要考虑的规律,因此我们看到在计算机世界中局部性原理,经常在速度不匹配的存储介质中得到运用,比如英特尔的CPU往往分为三级高速缓存,彼此之间的速度差距大概在8到10倍之间,其中高速缓存中的第三级缓存又比内存快10倍,这样彼此之间各差10倍左右的缓存体系加速效果最好,这就像军事行动中,先锋部队既要率先行动,又不能与大部队过于脱节,才能圆满的完成任务。在实际CPU的工作当中,如果数据单元A1被访问了,那么A1的邻居A0和A2被访问到的可能性也会极大的增加,因此CPU一般都会在数据单元A1被访问的同时,将他的邻居们调入高速缓存。

也就是说切片这种在内存当中连续分布的数据结构,其元素都是以高速缓存行的大小为单位读入到高速缓存的,而高速缓存的平均速度又是内存的几十倍,因此相当于一次读取操作,就能快速处理好几个元素;但由于哈希表实际也是稀疏表,一个键值对的周围可能没有其它有效键值对,因此哈希表在遍历时实际上只能一个一个元素的处理。这样比较下来哈希表在单个元素的访问上快,但在整体遍历上慢也就不足为奇了。

在元素不多不要用哈希表!我经常看到有不少程序员在元素不多的情况下,还坚持使用哈希表来建立key-value的对应关系,其实这样的做法并不会带来效率的提升,正如我们刚刚所说,哈希算法也被称为散列算法,键值对的内存地址分布很可能并不连续,这就特别不方便局部性原理发挥作用。刚刚我们上文也提到了内存缓存行的大小通常是64byte,在实际测试过程中可以看到如果元素能在一个内存缓存行存储下来,就不要用哈希表了,这时候用数据切片,每次遍历查找的性能反而比哈希表更快。具体代码如下:

哈希表实现示例

package main

import (

"fmt"

"time"

)

func main() {

testmap := make(map[int]int)

len := 10

times := 100000

//tests1ice := make([]int, len, len)

for i := 0; i < len; i++ {

testmap[i] = i + 1

}

sum := 0

now := time.Now().UnixNano()

for i := 0; i < times; i++ {

//for k, v := range testmap {

//if i%len == v {

sum = sum + i%len + testmap[i%len]

//break

//}

//}

//sum = sum + k + v

//tests1ice[i%len] = i + 1

}

diff := time.Now().UnixNano() - now

fmt.Println("sum=", sum)

fmt.Println("diff=", diff)

// fmt PrintIn("slice=", slice)

}




以上代码结果如下:

sum= 1000000

diff= 2929500​​




而切片遍历查找的实现如下:

package main

import (

"fmt"

"time"

)

func main() {

//testmap := make(map[int]int)

len := 10

times := 100000

tests1ice := make([]int, len, len)

for i := 0; i < len; i++ {

tests1ice[i] = i + 1

}

sum := 0

now := time.Now().UnixNano()

for i := 0; i < times; i++ {

for k, v := range tests1ice {

if i%len == k {

sum = sum + k + v

break

}

}

//sum = sum + k + v

//tests1ice[i%len] = i + 1

}

diff := time.Now().UnixNano() - now

fmt.Println("sum=", sum)

fmt.Println("diff=", diff)

// fmt PrintIn("slice=", slice)

}​​



sum= 810000

diff= 1953000

成功: 进程退出代码 0.​​



少元素方面集合的元素定位性能上,哈希表比切片慢了40%,当然这也是局部性原理造成的,由于元素比较少,因此切片这样内存连续数据结构,完全可以在高速缓存中完成数据的查找定位,这样综合下来其性能反而还要比哈希表要快。

正如前文所述,哈希算法的工作机制本身就决定了哈希表对存储空间就有一定的浪费,因此在没有性能优势的情况下,尤其是上述遍历及短表的场景下,就不要再用哈希表了,完全没有必要。

哈希表的实现机制要点

在笔者看了部分哈希表的代码之后,Java、Go和Rust这三种语言有一些相同的机制,也有一些不同,其中有两点值得关注,当然由于水平有限,如有错误之处敬请指正。

避免使用连续内存块:我们知道在内存、硬盘等存储设备的管理中,连续的空间往往是比较宝贵的,而哈希表是相对比较稀疏的数据结构,因此Java、Go和Rust基本都引用了一些比如桶的机制,尽量避免占用连续的内存块。以Go语言的实现为例:

type hmap struct {

count int // map的长度

flags uint8

B uint8 // map中的bucket的数量,

noverflow uint16 //

hash0 uint32 // hash 种子

buckets unsafe.Pointer // 指向桶的指针

oldbuckets unsafe.Pointer // 指向旧桶的指针,这里用于溢出

nevacuate uintptr

extra *mapextra // optional fields

}

// 在桶溢出的时候会用到extra

type mapextra struct {

overflow *[]*bmap

oldoverflow *[]*bmap

nextOverflow *bmap

}

type bmap struct {

tophash [bucketCnt]uint8// Map中的哈希值的高8位为桶的地址

}​​




在访问Map中的键值对时,需要先计算key的哈希值,其中哈希的值的低8位定位到具体的桶(bucket),通过高8位在桶内定位到具体的位置,而不同桶之间所占用的内存区域也不需要是连续的空间,这样也就从一定程度上弥补哈希表占用空间较大的缺点。

哈希碰撞处理:我们刚刚也介绍了哈希表碰撞的内容,也就是出现了不同的键值对要存储在同一个内存槽位的场景,极端情况下是所有键值对全部发生碰撞,这样哈希表实际也就退化成了链表,Java对碰撞的处理相对比较成熟,如果退化的链表长度大于8,那么Java会选择用红黑树这种近似于二叉排序树的数据结构进行替代,从而保证定位性能不低于O(logn)




而如果链表的长度小于等于8,那么如我们上文介绍,在数据长度比较短的情况下其实链表的性能可能还会更好,没必要使用引入红黑树,由此可见Java这门语言的确已经非常成熟。





版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

相关文章
C语言项目开发-项目架构和编程命名规范
一个项目的流程: 1、公司市场人员与客户交流,了解客户、引导客户使用公司最优资源并产出一份市场需求文档 2、公司需求人员(BA)与客户交流,了解客户需求并产出一个软件需求文档 3、项目经理、开发小组成员、需求人员(BA)一起开一个需求评审会议,对不合理的地方,    打回给BA,再由BA与客户沟通 4、程序员接到和充分了解软件需求文档后产生软件设计文档(包括概要设计文档和详细设计文档,    涉及到数据库的还需要进行数据库的设计) 5、程序员根据设计文档进行编码、调试、打包发布。
1292 0
DL之RNN:人工智能为你写代码——基于TF利用RNN算法实现生成编程语言代码(C++语言)、训练&测试过程全记录
DL之RNN:人工智能为你写代码——基于TF利用RNN算法实现生成编程语言代码(C++语言)、训练&测试过程全记录
56 0
【Java 从入坑到放弃】No 5. 控制流程
【Java 从入坑到放弃】No 5. 控制流程
12 0
JavaScript异步精讲,让你更加明白Js的执行流程!
JavaScript异步精讲,让你更加明白Js的执行流程! 问题点 什么是单线程,和异步有什么关系 什么是 event-loop jQuery的Deferred Promise 的基本使用和原理 async/await(和 Promise的区别、联系) 一、什么是单线程,和异步有什么关系 单线程.
1900 0
Python编程语言学习:仅需一行代码构造特殊列表之重复元素列表、等差数字列表等之详细攻略
Python编程语言学习:仅需一行代码构造特殊列表之重复元素列表、等差数字列表等之详细攻略
56 0
卧槽!最新编程语言排名,Java 沦为老二。。
2020 年 9 月刚过去,栈长看了下最新的 tiobe 编程语言榜,牛逼啊,C 语言居然登顶了,Java 下降 3 个点,沦为老二的位置。
27 0
37
文章
0
问答
来源圈子
更多
阿里云最有价值专家,简称 MVP(Most Valuable Professional),是专注于帮助他人充分了解和使用阿里云技术的意见领袖阿里云 MVP 奖项为我们提供了这样一个机会,向杰出的意见领袖表示感谢,更希望通过 MVP 将开发者的声音反映到我们的技术路线图上。
+ 订阅
文章排行榜
最热
最新
相关电子书
更多
JS零基础入门教程(上册)
立即下载
性能优化方法论
立即下载
手把手学习日志服务SLS,云启实验室实战指南
立即下载