哈希表(hash table)也叫散列表,应用场景及其丰富,许多缓存技术(比如memcached)核心就是在内存中维护一张大哈希表。对JDK7的HashMap源码进行分析。
一、什么是哈希表
二、HashMap实现原理(put、链表、重要字段、put操作过程、避免hash碰撞、扩容)
三、为何HashMap的数组长度一定是2的次幂?
四、重写equals方法需同时重写hashCode方法
五、总结
一、什么是哈希表
先大概了解下其他数据结构在新增,查找等基础操作执行性能
1、数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)
2、线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)
3、二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。
4、哈希表:添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)。
如何实现:物理存储结构只有两种:顺序和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),哈希表主干是数组,数组根据下标查找某个元素,一次定位就可以达到(比如新增或查找某元素,通过元素关键字,函数映射到数组中某个位置)
存储位置 = f 哈希函数 (关键字),函数设计好坏会直接影响到哈希表的优劣。插入操作:先通过哈希函数计算出实际存储地址,从数组中对应地址取出,查找同理
二、HashMap实现原理(put过程、解决冲突)
哈希桶(数组)+链表(解决冲突)
哈希桶:初始大小16,数组:链表头节点Entry对象。
1、put:
1)先算出key的hashcode,2)用hash函数算hash值(放entry中避免重复计算)3)再用indexFor计算哈希桶下标(说明容量2次幂)。4)得到hash桶位置,5)equals函数遍历链表,选择是插入还是更新。详细:
HashMap的主干是一个Entry数组(长度是2次幂,初始空数组{})。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。
transientEntry[] table = (Entry[]) EMPTY_TABLE;
Entry是HashMap中的一个静态内部类。
HashMap的整体结构如下
2、链表:解决哈希冲突
如定位数组位不含链表,查找,添加快,一次寻址;如包含链表,对于添加O(1),新Entry插入表头,查找要遍历链表,通过key对象equals方法逐一比对查找。链表越少,性能越好。
3、重要字段(上图)
HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值
initialCapacity默认为16,loadFactory默认为0.75。我们看下其中一个
常规构造器,没为数组table分配内存空间(有一个入参为指定Map的构造器例外),put操作的时候才真正构建table数组
4、put操作实现
2)inflateTable方法:为主干数组table在内存中分配存储空间,通过roundUpToPowerOf2(toSize)确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16; to_size=16,capacity=16; to_size=17,capacity=32.
roundUpToPowerOf2中的这段处理使得数组长度一定为2的次幂,Integer.highestOneBit是用来获取最左边的bit(其他bit位为0)所代表的数值.
2)hash函数(用了很多的异或,移位等运算,对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀)
3)hash函数计算出的值,通过indexFor进一步处理来获取实际的存储位置
h &(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为
最终计算出的index=2。有些版本的对于此处的计算会使用 取模运算,也能保证index一定在数组范围内,不过位运算对计算机来说,性能更高一些(HashMap中有大量位运算)
最终存储位置确定流程:
4)addEntry实现:
5、如何计算hash值(减少hash碰撞)
key==null 存入索引为0桶内,不空计算key的hashcode值:h右移16位异或
1)为什么要右移16位?
减少碰撞,降低hash冲突。int 4字节,右移16位异或 同时保留高16位于低16位特征
2)为什么要异或运算?
不这样做,直接&,那么高16位所代特征就可能丢失,高16无符号右移后 与 低16位异或,混合得新值,高低位信息都被保留 。如用&计算值向1靠拢,|运算值向0靠拢
6、扩容
1)目的:减少突率,提高查询性能。负载因子 loadFactor=size/capacity。越大冲突率越高,性能越差
2)扩容条件:哈希冲突 且 size>阈值0.75,要新建长度=数组2倍的新数组,将当前Entry数组中元素全传过去,耗资源
3)扩容危险:高并发hashmap触发一次扩容导致rt爆长,解决方案:
(1)concurrenthashmap:扩容的时候不影响读。问题是,扩容时会有锁(put方法持有),保证了读rt,无法保证写rt
(2)redis/memcached采用方法:实质上都是渐进式的rehash,只是redis是单线程不需要锁而已。memcached后台线程执行rehash的过程,首先获取锁,迁移一部分数据,再释放锁。循环迁移,直到完成。
建议设置大初始化容量,防止rehash,不能设置过大,浪费空间
三、为何HashMap的数组长度一定是2的次幂?
resize方法
如果数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index,我们先来看看transfer这个方法
这个方法将老数组中的数据逐个链表地遍历,扔到新的扩容后的数组中,我们的数组索引位置的计算是通过 对key值的hashcode进行hash扰乱运算后,再通过和 length-1进行位运算得到最终数组索引位置。
hashMap的数组长度一定保持2的次幂,比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换),个人理解。
还有,数组长度保持2的次幂,length-1的低位都为1,会使得获得的数组索引index更加均匀,比如:
我们看到,上面的&运算,高位是不会对结果产生影响的(hash函数采用各种位运算可能也是为了使得低位更加散列),我们只关注低位bit,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。这也是数组长度设计为必须为2的次幂的原因。
如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。
get方法
get方法通过key值返回对应value,如果key为null,直接去table[0]处检索。我们再看一下getEntry这个方法
可以看出,get方法的实现相对简单,key(hashcode)-->hash-->indexFor-->最终索引位置,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。要注意的是,有人觉得上面在定位到数组位置之后然后遍历链表的时候,e.hash == hash这个判断没必要,仅通过equals判断就可以。其实不然,试想一下,如果传入的key对象重写了equals方法却没有重写hashCode,而恰巧此对象定位到这个数组位置,如果仅仅用equals判断可能是相等的,但其hashCode和当前对象不一致,这种情况,根据Object的hashCode的约定,不能返回当前对象,而应该返回null,后面的例子会做出进一步解释。
四、重写equals方法需同时重写hashCode方法
重写了equals而不重写hashcode会发生什么样的问题
结果:null
get和put时,用key逻辑上是等值(通过equals比较是相等),但由于没有重写hashCode方法,所以put操作时,key(hashcode1)-->hash-->indexFor-->最终索引位置 ,而通过key取出value的时候 key(hashcode1)-->hash-->indexFor-->最终索引位置,由于hashcode1不等于hashcode2,没定位一个数组位置返回null(也有可能碰巧定位到一个数组位置,但是也会判断其entry的hash值是否相等,上面get方法中有提到。)
https://blog.csdn.net/qq_43658155/article/details/106346404