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这门语言的确已经非常成熟。





相关文章
|
1月前
|
安全 Java 开发工具
Java 编程语言
Java 是一门强大而重要的编程语言,具有广泛的应用和良好的发展前景,对于开发者来说,掌握 Java 是非常有价值的。
115 62
|
2月前
|
Java 开发者
在Java的集合世界里,Set以其独特的特性脱颖而出,它通过“哈希魔法”和“红黑树防御”两大绝技
【10月更文挑战第13天】在Java的集合世界里,Set以其独特的特性脱颖而出。它通过“哈希魔法”和“红黑树防御”两大绝技,有效抵御重复元素的侵扰,确保集合的纯洁性和有序性。无论是“人海战术”还是“偷梁换柱”,Set都能从容应对,成为开发者手中不可或缺的利器。
35 6
|
3月前
|
编译器 Go
go语言学习记录(关于一些奇怪的疑问)有别于其他编程语言
本文探讨了Go语言中的常量概念,特别是特殊常量iota的使用方法及其自动递增特性。同时,文中还提到了在声明常量时,后续常量可沿用前一个值的特点,以及在遍历map时可能遇到的非顺序打印问题。
|
4月前
|
安全 Java Go
Java&Go泛型对比
总的来说,Java和Go在泛型的实现和使用上各有特点,Java的泛型更注重于类型安全和兼容性,而Go的泛型在保持类型安全的同时,提供了更灵活的类型参数和类型集的概念,同时避免了运行时的性能开销。开发者在使用时可以根据自己的需求和语言特性来选择使用哪种语言的泛型特性。
59 7
|
4月前
|
Java
【Java集合类面试十四】、HashMap是如何解决哈希冲突的?
HashMap解决哈希冲突的方法是通过链表和红黑树:当链表长度超过一定阈值时,转换为红黑树以提高性能;当链表长度缩小到另一个阈值时,再转换回链表。
|
4月前
|
程序员 Go
Go 语言:面向对象还是非面向对象?揭开编程语言的本质
Go 语言:面向对象还是非面向对象?揭开编程语言的本质
|
4月前
|
分布式计算 安全 Java
Java帝国的无限魅力:揭秘这门万能编程语言如何征服科技世界,从Web到太空探索,Java的触角无处不在!
【8月更文挑战第12天】随着信息技术的发展,编程语言成为科技核心。Java以其成熟与广泛应用,在众多语言中脱颖而出。它支持跨平台运行,实现“一次编写,处处运行”。Java的面向对象特性促进代码复用与维护,内置的安全机制保障系统安全。Java应用于Web开发、大数据处理、移动应用等多个领域,展现了其不可替代的价值。
43 1
|
3月前
|
Rust Linux Go
Rust/Go语言学习
Rust/Go语言学习
|
4月前
|
Java Go 开发者
|
5月前
|
Java 编译器 开发者
Java演进问题之Truffle处理不同编程语言的源代码或中间格式如何解决
Java演进问题之Truffle处理不同编程语言的源代码或中间格式如何解决