【HashMap我可以讲半小时】

2022-03-07 13:16:15 浏览数 (1)

底层工作原理及数据结构

工作中用到最多的是hashmap,它支持key-value这种键值对存储。当往hashmap中添加一个键值对时,会将key-value的对应关系封装成一个Entry,就是键值对对象,它会拿着key做hash算法,把hash的值映射到内存地址,找到内存地址所在的位置,会查看这个位置有没有其他元素。如果没有其他元素,会把这个键值对直接放到一个node类型的数组中,这个数组就是hashmap底层基础的数据存储结构;如果有其他元素,会继续拿着这个key调用equals方法,和这个位置已存在的元素key进行对比,对比二个元素的key。key一样会返回一个true,原来的value值会被替换成新的value;key不一样返回flase,表示二个元素的key不一样,但是存储的位置是同一个,这个就是典型的hash碰撞,一般解决hash碰撞有四种办法。一种是开放寻址法,开放寻址法就是假设当前数组位置1,它存放了一个张三,现在要把李四也保存进来,但是这个位置被占用了,那么就把李四就放到下一个位置2上去,如果2也被占用了,就继续往下找,直到找到空位置。一种是拉链法,用的是链表的方式,位置1除了存放Entry还要保存一个next指针,指向数组外的另一个位置,将李四安排在这里,张三那个Entry中的next指针就指向李四的这个位置,也就是保存的这个位置的内存地址。如果还有冲突,就把冲突的那个Entry放到一个新位置上,然后李四的Entry指向它,这样就形成一个链表。一种是建立公共溢出区,将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。一种是再哈希法,当哈希地址发生冲突用其他的函数计算另一个哈希函数地址,直到冲突不再产生为止。hashmap就是采用拉链法这种方式用链表存储解决hash碰撞的。不过当链表中的数据较多时,查询的效率会下降,所以在JDK1.8版本后做了一个升级。当链表长度大于8并且数组长度大于64时,会转换为红黑树。如果链表长度大于8,但是数组长度小于64时,会进行扩容操作,不会转换为红黑树。红黑树需要进行左旋,右旋,变色操作来保持平衡,当数组长度小于64,使用数组加链表比使用红黑树查询速度要更快、效率要更高。在HashMap源码有这样一段描述,大致意思是说在理想状态下受随机分布的hashCode影响,链表中的节点遵循泊松分布。链表中的节点数是8的概率已经接近千分之一,这个时候链表的性能已经很差,所以在这种比较罕见和极端的情况下才会把链表转变为红黑树,大部分情况下HashMap还是使用链表,如果理想的均匀分布节点数不到8就已经自动扩容了。

HashMap实际使用过程中会出现一些性能问题以及线程安全问题

性能问题一般关注HashMap两个参数,这二个参数:初始容量和加载因子,影响hashmap的性能。初始容量只是哈希表在创建时的容量,这里的容量是哈希表中桶的数量,数组初始化容量是16。当哈希表中的条目数超出了加载因子与当前容量的乘积时,就要对该哈希表进行扩容、rehash,也就是重建内部数据结构,扩容后的哈希表将具有两倍的原容量。hashmap初始化容量时候,对容量大小做的处理,保证初始化容量为最近的2的幂次方,因为当数组长度为2的幂次方时,可以使用位运算来计算元素在数组中的下标,提高运算效率,除此之外还可以增加hash值的随机性,减少hash冲突。加载因子是用来判断当前HashMap<K,V>中存放的数据量,默认的加载因子是0.75。加载因子比较大,扩容发生的频率比较低,浪费的空间比较小,发生hash冲突的几率比较大。比如,加载因子是1的时候,hashmap长度为128,实际存储元素的数量在64至128之间时间段比较多,这个时间段发生hash冲突比较多,造成数组中其中一条链表比较长,会影响性能。加载因子比较小,扩容发生的频率比较高,浪费的空间比较多,发生hash冲突的几率比较小。比如,加载因子是0.5的时候,hashmap长度为128,当数量达到65的时候会触发扩容,扩容后为原理的256,256里面只存储了65个,浪费了。综合了一下,取了一个平均数0.75作为加载因子。当负载因子为0.75,代入到泊松分布公式,计算出来长度为8时,概率=0.00000006,概率很小了,这也是为什么上面我们提到的当链表长度为8时才会转红黑树的原因。对比jdk1.7和1.8版本可以发现,1.7版本的时候有二个地方是有些问题的,第一个是扩容的时候需要rehash操作,将所有的数据重新计算HashCode,然后赋给新的HashMap,rehash的过程是非常耗费时间和空间的。 第二个是当并发执行扩容操作时会造成环形链和数据丢失的情况,开多个线程不断进行put操作,当旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,就是因为头插法,所以最后的结果打乱了插入的顺序,就有可能发生环形链和数据丢失的问题,引起死循环,导致CPU利用率接近100%。在JDK1.8中,对HashMap这二点进行了优化。第一点是经过rehash之后元素的位置,要么是在原位置,要么是在原位置再移动2次幂的位置。HashMap的数组长度恒定为2的n次方,也就是说只会为16,32,64,128这种数。即便你给的初始值是13,最后数组长度也会变成16,它会取与你传入的数最近的一个2的n次方的数。在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成原位置 原数组长度。HashMap中运算数组的位置使用的是数组长度-1,因为每次扩容会把原数组的长度*2,那么再二进制上的表现就是多出来一个1。比如初始长度为16的数组,对应的leng-1就是15,原数组15二进制后八位为0000 1111。扩容后的长度为32的数组,对应的leng-1就是31,二进制就变成了0001 1111。再次扩容长度为64的数组,对应的leng-1就是63,二进制是0011 1111。扩容之后元素的位置是否改变则取决于与这个多出来的1的运算结果,运算结果为0则不需要换位置,运算结果为1则换新位置,新位置为老位置的高位进1。举二个例子说明这个现象,假设某个元素的hashcode为52,这个52与15运算做按位与运算的的结果是4,这个52与31做按位与运算的的结果是20,20不就是等于4 16吗,刚好是原数组的下标 原数组的长度。假设某个元素的hashcode为100,100&15=4,100&31=4,对于HashCode为100的元素来说,扩容后与扩容前其所在数组中的下标均为4。所以经过rehash之后,元素的位置要么是在原位置,要么是在原位置加原数组长度的位置。既省去了重新计算hash值的时间,而且由于新增的1bit是0还是1,可以认为是随机的,复杂度更高,从而让分布性更高一些。第二点,发生hash碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程环境下,会发生数据覆盖的情况,如果没有hash碰撞的时候,它会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,线程A会把线程B插入的数据给覆盖,导致数据发生覆盖的情况,发生线程不安全。实际的报错现象就是:java.util.ConcurrentModificationException并发修改异常。导致原因:并发争取修改导致,一个线程正在写,一个线程过来争抢,导致线程写的过程被其他线程打断,导致数据不一致。解决这种问题也有多种方案:第一种解决方案:使用HashTable:HashTable是线程安全的,只不过实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁。多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。第二种解决方案:使用工具类Collections.synchronizedMap(new HashMap<>());和Hashtable一样,实现上在操作HashMap时自动添加了synchronized来实现线程同步,都对整个map进行同步,在性能以及安全性方面不如ConcurrentHashMap。第三种解决方案:使用写时复制:CopyOnWrite:往一个容器里面加元素的时候,不直接往当前容器添加,而是先将当前容器的元素复制出来放到一个新的容器中,然后新的元素添加元素,添加完之后,再将原来容器的引用指向新的容器,这样就可以对它进行并发的读,不需要加锁,因为当前容器不添加任何元素。利用了读写分离的思想,读和写是不同的容器。会有内存占用问题,在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存。会有数据一致性问题,CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。第四种解决方案:使用ConcurrentHashMap:ConcurrentHashMap大量的利用了volatile,CAS等技术来减少锁竞争对于性能的影响。在JDK1.7版本中ConcurrentHashMap避免了对全局加锁,改成了局部加锁(分段锁),分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。不过这种结构的带来的副作用是Hash的过程要比普通的HashMap要长。所以在JDK1.8版本中CurrentHashMap内部中的value使用volatile修饰,保证并发的可见性以及禁止指令重排,只不过volatile不保证原子性,使用为了确保原子性,采用CAS(比较交换)这种乐观锁来解决。CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。volatile有三个特性:可见性,不保证原子性,禁止指令重排。可见性:线程1从主内存中拿数据1到自己的线程工作空间进行操作(假设是加1)这个时候数据1已经改为数据2了,将数据2写回主内存时通知其他线程(线程2,线程3),主内存中的数据1已改为数据2了,让其他线程重新拿新的数据(数据2)。不保证原子性:线程1从主内存中拿了一个值为1的数据到自己的工作空间里面进行加1的操作,值变为2,写回主内存,然后还没有来得及通知其他线程,线程1就被线程2抢占了,CPU分配,线程1被挂起,线程2还是拿着原来主内存中的数据值为1进行加1,值变成2,写回主内存,将主内存值为2的替换成2,这时线程1的通知到了,线程2重新去主内存拿值为2的数据。禁止指令重排:首先指令重排是程序执行的时候不总是从上往下执行的,就像高考答题,可以先做容易的题目再做难的,这时做题的顺序就不是从上往下了。禁止指令重排就杜绝了这种情况。

HashMap的使用优化

对hashmap使用的优化,我个人看法有六点,第一点,建议采用短String,Integer这样的类作为键。特别是String,他是不可变的,也是final的,而且已经重写了equals 和hashCode方法,这个和HashMap 要求的计算hashCode的不可变性要求不谋而合,核心思想就是保证键值的唯一性,不变性,其次是不可变性还有诸如线程安全的问题,以上这么定义键,可以最大限度的减少碰撞的出现。如果hashCode 不冲突,那查找效率很高,但是如果hashCode一旦冲突,要调用equals一个字节一个自己的去比较,key越短效率越高。第二点迭代器遍历Map,在各个数量级效率稳定且较高,一般采用Iterator迭代器遍历Map,使用迭代器遍历entrySet在各个数量级别效率都比较高。第三点concurrentHashMap或迭代器Iterator遍历删除,当遍历Map需要删除的时候,不可以for循环遍历,否则会产生并发修改异常CME,只能使用迭代器iterator.remove()来删除元素,或者使用线程安全的concurrentHashMap来删除Map中的元素。第四点考虑加载因子地设定初始大小,设定时一定要考虑加载因子的存在。使用的时候最好估算存储的大小,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。Guava的做法是把默认容量的数字设置成预期大小 / 0.75F 1.0F,可以使用Maps.newHashMapWithExpectedSize(预期大小);来创建一个HashMap,计算的过程guava会帮我们完成第五点减小加载因子,如果Map是一个长期存在而不是每次动态生成的,而里面的key又是没法预估的,那可以适当加大初始大小,同时减少加载因子,降低冲突的机率。毕竟如果是长期存在的map,浪费点数组大小不算啥,降低冲突概率,减少比较的次数更重要。第六点使用IntObjectHashMap,HashMap的结构是 Node[] table; Node 下面有Hash,Key,Value,Next四个属性。而IntObjectHashMap的结构是int[] keys 和 Object[] values。在插入时,同样把int先取模落桶,如果遇到冲突,则不采样HashMap的链地址法,而是用开放地址法(线性探测法)index+1找下一个空桶,最后在keys[index],values[index]中分别记录。在查找时也是先落桶,然后在key[index ]中逐个比较key。所以,对比整个数据结构,省的不止是int vs Integer,还有每个Node的内容。性能IntObjectHashMap还是稳赢一点的,随便测了几种场景,耗时至少都有24ms vs 28ms的样子,好的时候甚至快1/3。

整个图片,歇歇眼,文章大多不换行,排版基本都是一块的,主要是针对面试口述而已的,备战面试。

0 人点赞