关于集合,里面最常用就是这几个Hash集合,今天在此重新梳理下。
其实前面看过很多关于HashMap的描述,没有自己思考用过,今天在此总结下!决定年前对集合有一个深刻认识。
感觉可能对数据结构没有清晰认识,所以对Hash表理解也不深刻。
一、什么是哈希表
再讨论哈希表之前,我们先大概了解下其他数据结构在新增、查找等方向上的执行性能。
数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1)。通过给定值进行查找,需要遍历数组,逐一对比给定关键字和数组元素,时间复杂度为O(n);当然,对于有序数组,则可以采用二分法查找、插值查找等方式,可将查找复杂度提高为O(logn)。对于一般的插入删去操作,涉及到数组元素的移动,其平均复杂度为O(n)。 容易查找,不容易插入和删去。
线性链表:对于链表的新增、删去等操作(再找到指定操作位置后),仅需处理节点间的引用即可。时间复杂度为O(1)。而查找操作需要遍历链表逐一进行比对,复杂度为O(n)。
二叉树:对一颗相对平衡的有序二叉树,对其进行插入、查找、删去等操作,平均复杂度均为O(logn)。
数组:存储区间连续,占用内存严重,寻址容易,插入删除困难;
链表:存储区间离散,占用内存比较宽松,寻址困难,插入删除容易;
Hashmap综合应用了这两种数据结构,实现了寻址容易,插入删除也容易。并且在JDK1.8中引入了红黑树。
哈希表:相比上述几种数据结构,在哈希表中进行添加、删去、查找等操作,性能十分之高。不考虑哈希冲突的情况下,仅需一次定位即可完成。时间复杂度为O(1)。
我们知道,数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。
比如我们要新增或查找某个元素,我们通过把当前元素的某个关键字通过某个函数映射到数组中的某个位置。通过数组下标一次定位就可完成操作。
存储位置=f(关键字)
其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:
哈希冲突
然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。
- JDK7 使用了数组+链表的方式
- JDK8 使用了数组+链表+红黑树的方式
二、HashMap实现原理
HashMap的主干是一个Entry数组,Entry是HashMao的基本组成单元,每一个Entry包含一个Key-value键值对。
在JDK1.8的时候将Entry转为Node节点,下面先以JDK1.7为例:
1 //HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂,至于为什么这么做,后面会有详细分析。 2 static final Entry<?,?>[] EMPTY_TABLE = {}; 3 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry是HashMap中的一个静态内部类。代码如下
1 static class Entry<K,V> implements Map.Entry<K,V> { 2 final K key; 3 V value; 4 Entry<K,V> next; 5 int hash; 6 7 /** 8 * Creates new entry. 9 */ 10 Entry(int h, K k, V v, Entry<K,V> n) { //从这个地方可以看出,新插入的节点是插在链表头部还是尾部 11 value = v; 12 next = n; 13 key = k; 14 hash = h; 15 } 16 。。。。。。。。。。。。。。重写了hashCode()和equal()两个方法 17 18 }
所以,HashMap的整体结构如下:
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
到这里,估计对HashMap的结构应该很清晰了。
HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值initialCapacity默认为16,loadFactory默认为0.75。
在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组。
下面我们重点分析下put操作:
1 public V put(K key, V value) { 2 //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=16) 3 if (table == EMPTY_TABLE) { 4 inflateTable(threshold); 5 } 6 //如果key为null,存储位置为table[0]或table[0]的冲突链上 7 if (key == null) 8 return putForNullKey(value); 9 int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀 10 int i = indexFor(hash, table.length);//获取在table中的实际位置 11 for (Entry<K,V> e = table[i]; e != null; e = e.next) { //这个for循环仅仅只用来进行覆盖 12 //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value 13 Object k; 14 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { 15 V oldValue = e.value; 16 e.value = value; 17 e.recordAccess(this); 18 return oldValue; 19 } 20 } 21 modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败 22 addEntry(hash, key, value, i);//新增一个entry,会加入到链表中 23 return null; 24 }
中间一些细节code不在此多讲。存储位置的确定流程是这样的:
三、JDK1.8中HashMap
基本概念
- 节点:
Node<Key,Value>
,存放key和value
1 static class Node<K,V> implements Map.Entry<K,V> { 2 final int hash; 3 final K key; 4 V value; 5 Node<K,V> next; 6 }
- 键值对数组:
Node<K,V>[] table
- 加载因子
- 容量 :Node数组的长度
- 大小:hashmap存放的Node的数目
- 阈值:容量*加载因子
工作原理
- 创建一个长度为2的n次幂的Node数组
- put的时候,计算key的hash值,将hash值与长度-1进行与运算
- 如果数组该下标的位置为空,直接存放。如果不为空,判断节点是否是树节点,如果是的话,按照红黑树的方式插入加点。若不是则按照链表加入。
- 当hashMap的节点数目大于阈值的时候,将会重新构建HashMap。但是这种操作很费时,尽量初始化时就设计好。
HashMap具体的存取过程如下:
put键值对的方法的过程是:
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;注意第一次table的初始化也是在这。
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
理解一下大概流程,我们接下来看代码:
/** * 计算key的hash值 */ static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } /** * 插入key-value */ public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /** * Implements Map.put and related methods * * @param hash hash for key * @param key the key * @param value the value to put * @param onlyIfAbsent if true, don't change existing value * @param evict if false, the table is in creation mode. * @return previous value, or null if none */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //1. 如果当前table为空,新建默认大小的table if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //2. 获取当前key对应的节点 if ((p = tab[i = (n - 1) & hash]) == null) //3. 如果不存在,新建节点 tab[i] = newNode(hash, key, value, null); else { //4. 存在节点 Node<K,V> e; K k; //5. key的hash相同,key的引用相同或者key equals,则覆盖 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //6. 如果当前节点是一个红黑树树节点,则添加树节点 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //7. 不是红黑树节点,也不是相同节点,则表示为链表结构 else { for (int binCount = 0; ; ++binCount) { //8. 找到最后那个节点 if ((e = p.next) == null) { //9.在链尾插入节点 p.next = newNode(hash, key, value, null); //10. 如果链表长度超过8转成红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //11.如果链表中有相同的节点,则覆盖 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; //12.是否超过容量,超过需要扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
我们来分析关键的一步,2. 获取当前key对应的节点 (p = tab[i = (n - 1) & hash]) == null 当中的这一句tab[i = (n - 1) & hash 我们可以知道这个tab其实就是Map中实体数组。然后我们通过计算下标 i = (n-1) & hash来获取对应节点。n是当前tab的的长度,hash是将key通过hash()方法获取的。所以我们获取到这个节点的下标就是经过了以下几步:
1. 计算出key的hashCode,记为h
2. h与h右移16位进行异或运算,记为hash
3. hash与当前tab的length-1 进行与运算。得出下标
通过hashCode的高16位异或低16位,优化高位运算的算法。这么做可以在数组table的length比较小的时候,也能保证高低位参与到hash计算中,又不会有很大的消耗。接着,用到了table的length和hash进行与运算。前面一定保证table的length是2的n次方。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。所以这是hashmap对取模的优化。
这也就解释了Node中hash的值怎么来的,以及它的作用。putVal的流程也就解释了怎么塞进数组指定位置,什么时候用链表,什么时候转成红黑树,这些问题。
发生冲突关于entry节点插入链表还是链头呢?
JDK7:插入链表的头部,头插法
JDK8:插入链表的尾部,尾插法
阅读源码发现,如果遍历链表都没法发现相应的key值的话,则会调用addEntry方法在链表添加一个Entry,重点就在与addEntry方法是如何插入链表的,addEntry方法源码如下:
1 void addEntry(int hash, K key, V value, int bucketIndex) { 2 Entry<K,V> e = table[bucketIndex]; 3 table[bucketIndex] = new Entry<K,V>(hash, key, value, e); 4 if (size++ >= threshold) 5 resize(2 * table.length); 6 }
这里构造了一个新的Entry对象(构造方法的最后一个参数传入了当前的Entry链表),然后直接用这个新的Entry对象取代了旧的Entry链表,看一下Entry的构造方法可以知道是头插法。
1 Entry( int h, K k, V v, Entry<K,V> n) { 2 value = v; 3 next = n; 4 key = k; 5 hash = h; 6 }
从构造方法中的next=n
可以看出确实是把原本的链表直接链在了新建的Entry对象的后边,可以断定是插入头部。
JDK8
还是继续查看put方法的源码查看插入节点的代码:
1 //e是p的下一个节点 2 if ((e = p.next) == null) { 3 //插入链表的尾部 4 p.next = newNode(hash, key, value, null); 5 //如果插入后链表长度大于8则转化为红黑树 6 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 7 treeifyBin(tab, hash); 8 break; 9 }
从这段代码中可以很显然地看出当到达链表尾部(即p是链表的最后一个节点)时,e被赋为null,会进入这个分支代码,然后会用newNode方法建立一个新的节点插入尾部。
下面一片学习CurrentHashMap.
https://www.cnblogs.com/chengxiao/p/6059914.html--------主要参考这个帖子,里面写的很详细。JDK1.7.
https://blog.csdn.net/soga613/article/details/78958642-----对JDK1.8中hashMap进行深入讲解,对put和resize()都进行讲解。
https://blog.csdn.net/visant/article/details/80045154 和上面类似,有put框图
http://www.cnblogs.com/-new/p/7472455.html-----Jdk1.8,详细参考
https://blog.csdn.net/v123411739/article/details/78996181-------JDK1.8 对所有函数都进行了讲解,非常详细,没有看完。
https://www.cnblogs.com/skywang12345/p/3310835.html ---对JDK1.6一些源码接口API讲解
https://www.cnblogs.com/skywang12345/category/455711.html------------------一系列文章,值得好好学习
https://www.jianshu.com/p/a7767e6ff2a2------插入链表是在头部还是尾部,JDK1.7是头部插入,JDk1.8是尾部插入。 currentHashMap
https://www.cnblogs.com/fsychen/p/9361858.html------currentHashMap
https://blog.csdn.net/fjse51/article/details/55260493-------currentHashMap
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· winform 绘制太阳,地球,月球 运作规律
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人