龙空技术网

HashMap实现原理

架构知识 57

前言:

此时看官们对“二进制重排的本质是什么”可能比较关心,姐妹们都想要了解一些“二进制重排的本质是什么”的相关资讯。那么小编也在网上汇集了一些有关“二进制重排的本质是什么””的相关文章,希望我们能喜欢,兄弟们快快来学习一下吧!

一、介绍1、HashMap的概念与作用

HashMap是Java中的一个数据结构,用于存储键值对(key-value pairs)。它基于哈希表的实现机制,支持快速的插入和查找操作。HashMap内部实现了一个数组bucket,每个bucket又是一个链表,元素会插入到链表中。

在HashMap中,键和值都可以是任意类型的对象,通过键值对来存储和访问数据,使用键查找值的速度非常快。因此,HashMap是实现缓存、索引和映射等功能的理想选择。

HashMap的作用有很多,包括:

(1)存储键值对数据,支持快速的查找和插入操作;

(2)实现缓存,将一些常用的结果缓存于内存中,减少数据库或网络的访问次数,提升了系统的性能;

(3)映射表,将一种对象映射为另一种对象,实现数据转换;

(4)计数器,用于计量某个事件发生的次数等。

2、HashMap的应用场景

HashMap的应用场景非常广泛,主要用于以下几个方面:

缓存数据:HashMap 可以很方便地扮演缓存的角色,将某些数据缓存到内存中,避免多次查询或计算,提高程序的运行效率。

检索数据:HashMap 支持快速查找键值对数据,适用于需要频繁查询、添加、删除某些数据的场景。

记录数据采集:例如网站分析工具,可以记录用户的访问行为,使用 HashMap 来存储具体的访问情况。

代替数组:当需要频繁的操作数组时,可以考虑使用 HashMap 来代替数组,比如需要通过 ID 查找对象,同时需要更改对象时,使用 HashMap 维护 ID 和对象的映射关系即可。

实现数据映射:HashMap 具有将一种对象映射为另一种对象的能力,可用于实现数据转换,例如利用HashMap将代码中的国际化字符串映射成具体的内容。

二、基本结构1、HashMap的基本结构

HashMap 是以一种数组结构来实现的;数组中的每个元素都是一个链表;链表中存储了一组键值对。HashMap 的基本结构如下:

HashMap 接口:

public interface Map<K,V> {...}

HashMap 类:

public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable {...}

HashMap 内部结构:

transient Node<K,V>[] table;static class Node<K,V> implements Map.Entry<K,V> {	final int hash;	final K key;	V value;	Node<K,V> next;	...}

其中,table 是底层的数据结构,用于存储所有的键值对,是一个 Node 类型的数组。每个 Node 表示一个键值对,其中 hash 存储了键的哈希值,key 存储了键,value 存储了值,next 是一个指向下一个节点的指针。

当需要向 HashMap 中添加元素时,会将键值对插入到 table 中相应的位置(计算得到的索引)上。若在该位置上已经有元素了,会将新元素添加到链表的末尾。当遇到哈希冲突时,即两个键在 table 上散列到了同一个索引的位置上,会将新元素插入到链表的头部。在查找元素时,先根据键的哈希值得到键所在的索引位置,再遍历链表找到指定键的节点。其中,哈希函数和哈希冲突的处理策略是影响 HashMap 性能的重要因素之一。

2、数组与链表的组合

数组和链表是两种基础的数据结构,在计算机科学中有着广泛的应用。它们各有优缺点,可以通过组合使用来发挥其优势,实现各种高效的算法和数据结构。

在数据结构中,一种常见的组合是将数组和链表结合起来,形成一种新的结构。具体而言,我们可以将数组用于存储结点的位置信息,链表用于存储每个结点的具体数据和指向下一个结点的指针信息。这种组合结构通常称为“链式前向星”或“动态数组”。

链式前向星常用于解决对图中边的遍历问题。通过存储每个结点的位置和指向下一个结点的指针信息,链式前向星能够在 O(m+n) 的时间复杂度下遍历整个图,其中 n表示结点的数量,m 表示边的数量。相比于纯链表或纯数组,它具有更高的效率和更优异的空间利用率。

此外,链式前向星还可以应用于其他数据结构的实现,如优先队列、哈希表等。通过基于数组进行动态扩展和基于链表进行增删操作等优化,可以实现高效的数据结构操作,提高算法的效率和性能。

三、存储元素1、HashMap中元素的存储方式

在 Java 中,HashMap 是一种常用的哈希表实现,用于存储键值对。HashMap 中的元素实际上是以 Node 的形式存储的。每个 Node 包含 key、value 和指向下一个 Node 的指针(因为 HashMap 内部是使用链表实现的)。以下是 Node 的定义:

static class Node<K, V> implements Map.Entry<K, V> {	final int hash;	final K key;	V value;	Node<K, V> next;	Node(int hash, K key, V value, Node<K, V> next) {		this.hash = hash;		this.key = key;		this.value = value;		this.next = next;	}	// 省略一些重写的方法}

其中:

hash 表示键值对的哈希值,会通过哈希算法映射到 HashMap 中的一个桶(bucket)中。key 表示键值对的键,用于查找和比较。value 表示键值对的值。next 表示指向下一个 Node 的指针,用于解决哈希冲突(即多个键的哈希值到同一个桶的情况)。

HashMap 的实现通过哈希算法将键值对分散存储到不同的桶中,每个桶实际上是一个链表。当哈希冲突发生时,新的键值对会被添加到链表的最前端,这样可以保证链表顺序与添加的顺序一致,并且能够在插入、查找和删除元素时保持较好的性能。

2、在存储元素时如何计算hash值

在 HashMap 中计算元素的哈希值是通过元素的 hashCode() 方法实现的。在 Java 中,Object 类中定义了一个 hashCode() 方法,用于计算对象的哈希值。默认情况下,hashCode() 方法返回的哈希值是对象的内存地址的一个整数表示。但是,我们可以在自定义类中重写 hashCode() 方法,根据对象的属性计算出一个固定的哈希值,这样能够保证同样内容的对象拥有相同的哈希值。

在 HashMap 中,计算元素的哈希值的具体过程如下:

获取元素的 hashCode() 值。

int hash = key.hashCode();
将哈希值与 HashMap 的容量减 1 进行按位与操作,得到桶的索引位置。
int bucketIndex = hash & (capacity - 1);

其中,capacity 表示 HashMap 的容量。由于 capacity 通常是 2 的幂次方,因此 (capacity - 1) 可以得到一个二进制全是 1 的数,例如:

当 capacity = 8 时,(capacity - 1) = 7,二进制表示为 00000111;当 capacity = 16 时,(capacity - 1) = 15,二进制表示为 00001111;当 capacity = 32 时,(capacity - 1) = 31,二进制表示为 00011111。

按位与操作能够确保桶的索引位置在 0 ~ (capacity - 1) 之间,并且能够均匀地分散到不同的桶中。

将元素插入到对应的桶中。

如果将元素添加到的桶中已经存在其他元素(即出现了哈希冲突),则将新元素添加到该桶中的链表的头部位置。这样能够保证在查找元素时,优先查找最近插入的元素,从而提升查找元素的效率。

四、冲突处理1、拉链法,链表的头部添加元素

拉链法的思想是在哈希表的每个桶中维护一个链表,将映射到该桶中的元素都放入这个链表中。当遇到哈希冲突时,只需要在对应桶的链表头部添加新元素即可。

举个例子,假设有一个哈希表,使用拉链法进行处理,其中桶的索引位置从 0 到 2,每个桶中均使用链表存储元素。现在依次添加 6 个元素,分别为 1、3、13、21、23、24。它们的哈希值分别为 1、0、2、0、2、2,因此会被分别放入桶 1、0 和 2 中。

按照拉链法,将这些元素插入到相应的链表中,得到如下结果:

索引 0:3 -> 21 -> null
索引 1:1 -> null
索引 2:13 -> 23 -> 24 -> null

以上便是拉链法的基本思想和实现方法,通过链表解决哈希冲突,能够保证哈希表的查找、插入和删除操作的时间复杂度都是 O(1) 的。

2、开放地址法:线性探测、二次探测、双重散列处理冲突

开放地址法的三种常用方法:线性探测、二次探测和双重散列。使用开放地址法处理哈希冲突,能够保证哈希表的查找、插入和删除操作的时间复杂度都是 O(1) 的。

(1)线性探测

线性探测的思想是,当哈希桶中某个位置已经被占用时,就往后顺延一个位置,直到找到一个空闲位置。

具体来说,假设某个元素的哈希值为 h,首先将其放在哈希表的第 h 个位置上,如果这个位置已经被占用,就顺延到第 h+1 个位置,再判断是否被占用,如果还被占用,就继续往后顺延,直到找到一个空闲位置,将元素插入到这个位置中。

例如,假设有一个哈希表,使用线性探测法进行处理,其中的哈希函数为 h(x) = x % 7。现在依次加入元素 16、15、8、23、13、17、7、22,如果出现哈希冲突,就往后顺延,直到找到空闲位置。具体的插入过程如下:

插入元素 16,插入位置 2。插入元素 15,插入位置 1。插入元素 8,插入位置 1(与元素 15 发生冲突,顺延到下一个位置)。插入元素 23,插入位置 2(与元素 16 发生冲突,顺延到下一个位置)。插入元素 13,插入位置 6。插入元素 17,插入位置 3。插入元素 7,插入位置 0。插入元素 22,插入位置 1(与元素 15 和 8 发生冲突,顺延到下一个位置)。

最终得到的哈希表为:

位置 0:7位置 1:15 8 22位置 2:16 23位置 3:17位置 4:位置 5:位置 6:13
(2)二次探测

二次探测的思想是,当哈希桶中某个位置已经被占用时,就向后跳一定的步数(步数必须与哈希表的大小互质),直到找到一个空闲位置。

具体来说,假设某个元素的哈希值为 h,首先将其放在哈希表的第 h 个位置上,如果这个位置已经被占用,就往后跳 1^2、2^2、3^2、... 直到找到一个空闲位置,将元素插入到这个位置中。

例如,假设有一个哈希表,使用二次探测法进行处理,其中的哈希函数为 h(x) = x % 7。现在依次加入元素 16、15、8、23、13、17、7、22,如果出现哈希冲突,就往后跳一定的步数。具体的插入过程如下:

插入元素 16,插入位置 2。插入元素 15,插入位置 1。插入元素 8,插入位置 4(与元素 15 发生冲突,跳 1^2 个位置)。插入元素 23,插入位置 2(与元素 16 发生冲突,跳 2^2 个位置)。插入元素 13,插入位置 3(与元素 8 发生冲突,跳 3^2 个位置)。插入元素 17,插入位置 5。插入元素 7,插入位置 0。插入元素 22,插入位置 6(与元素 15、8 和 23 发生冲突,跳 4^2 个位置)。

最终得到的哈希表为:

位置 0:7位置 1:15位置 2:16 23位置 3:13位置 4:8位置 5:17位置 6:22
(3)双重散列

双重散列的思想是,当哈希桶中某个位置已经被占用时,就按照另一个哈希函数寻找下一个位置。

具体来说,假设某个元素的哈希值为 h1,首先将其放在哈希表的第 h1 个位置上,如果这个位置已经被占用,就按照另一个哈希函数 h2,寻找下一个位置,直到找到一个空闲位置。

例如,假设有一个哈希表,使用双重散列法进行处理,其中的两个哈希函数分别为 h1(x) = x % 7 和 h2(x) = 5 - x % 5。现在依次加入元素 16、15、8、23、13、17、7、22,如果出现哈希冲突,就按照另一个哈希函数寻找下一个位置。具体的插入过程如下:

插入元素 16,插入位置 2。插入元素 15,插入位置 1。插入元素 8,插入位置 6(与元素 15 发生冲突,按照 h2 函数跳到 6 位置)。插入元素 23,插入位置 2(与元素 16 发生冲突,按照 h2 函数跳到 4 位置)。插入元素 13,插入位置 3。插入元素 17,插入位置 5。插入元素 7,插入位置 0。插入元素 22,插入位置 1。

最终得到的哈希表为:

位置 0:7位置 1:15 22位置 2:16 23位置 3:13位置 4:位置 5:17位置 6:8
五、扩容与重排

在 HashMap 中,扩容(rehashing)是必须进行的。当 HashMap 中元素的数量超过了负载因子(load factor)和当前 bucket 数量的乘积时,就需要对 HashMap 进行扩容。

负载因子是指 HashMap 允许的最大负载比例,它的默认值为 0.75。当 HashMap 中的元素数量到达当前 bucket 数量和负载因子的乘积时,就需要对 HashMap 进行扩容,这是为了保证 HashMap 中的元素查找、插入、删除等操作的时间复杂度始终能够保持在 O(1) 左右。

具体的扩容过程是:

首先创建一个新的 bucket 数组,其大小为原 bucket 数组大小的两倍。

然后,将旧 bucket 数组中的元素逐个重新分配到新的 bucket 数组中,在分配的过程中,会使用 hashCode 和新 bucket 数组的大小计算出新的位置。

由于扩容的过程会涉及到 bucket 数组的复制、元素的重组等操作,因此扩容的时间复杂度并不是 O(1),而是 O(n),其中 n 为元素的数量。当 HashMap 中的元素非常多时,扩容的时间可能会比较长,会对性能造成一定的影响。

因此,在实际应用中,为了防止 HashMap 中元素过多而导致频繁扩容,可以在创建 HashMap 时指定初始容量,以减少扩容的次数,提高性能。

六、线程安全

由于HashMap是非线程安全的类,多个线程可以同时对HashMap进行读写操作,这可能会导致数据读写冲突和不一致性的问题。

在多线程环境下,如果需要使用HashMap,可以考虑以下两种方法来确保安全性:

1、使用线程安全的Map实现类:Java提供了线程安全的Map实现类,如Hashtable和ConcurrentHashMap可以用来替代HashMap,它们提供了线程安全的Map操作方法,可以保证多线程环境下的数据一致性和线程安全性。

3、使用同步方法或同步块保证线程安全:另一种方法是在HashMap的操作方法上添加同步代码块或方法,将对HashMap的操作变成线程安全的。这种方法虽然可以解决线程安全问题,但会影响程序的执行效率和吞吐量。

因此,在高并发环境下,推荐使用线程安全的Map实现类,而不是在代码中手动添加同步机制。

标签: #二进制重排的本质是什么