Java基础 HashMap实现原理及方法

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/xiaoxiaovbb/article/details/79895837

1、什么是HashMap?

        HashMap通常提起他,我们想到的就是键值对方式存储(key-value型式),可以接收null键值和null值。基于Map接口的非同步实现(也就是线程不安全),并不保证映射的顺序,特别不保证这个顺序恒久不变

            下图是HashMap的源码,可以看到它继承自AbstractMap,实现Map,Cloneable,Serializable接口。其一些默认值都一一列出:


            其中,modCount这个属性值是记录HashMap内部结构发生变化(指的是内部结构,相同的key,put()一个value这种覆盖原来的值不属于结构变化)的次数,主要用于迭代的快速失败。

            threshold来判断HashMap的最大容量,threshold = (int)(capacity * loadFactor);

            loadFactor负载因子:散列表的实际元素数目/散列表的容量。

          其衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高(装东西越满),使用链表法 的散列 表空间利用越充分,查找时间则变长效率降低;越小则结论与越大相反。

           Entry为HashMap的静态内部类,从上面可以看出Entry为承载key-value的值,并且默认table为Entry数组。下面看一下Entry的源码:


        put时如果key传入为null,那么value一定会为null,无论value输入什么.并且map.put()/get()的返回值是value的类型。下面是对应的小栗子:

Map<String, String> map2 = new HashMap<String,String>();
String r2 = map2.put(null, null);
System.out.println("r2 "+r2);
String r3 = map2.get(null);
System.out.println("r3 "+r3);
String r4 = map2.put(null, "32");

扫描二维码关注公众号,回复: 2967486 查看本文章

System.out.println("r4 "+r4);

            输出 结果:r2 null

                              r3 null

                              r4 null


2、HashMap的数据结构是什么样的呢?

        jdk的1.8之前的版本和1.8及之后的版本HashMap的数据结构出现了一点变化。我们这里主要介绍1.8之前的数据结构。主要是数组+链表的结构,上面给出的属性源码也可以看出HashMap是这样的数据结构(table数组,Entry类为一个链表的节点来存储Key-value),也叫拉链法(链表数组)。

        在1.8之后HashMap的数据结构出现了改变,变成数组+链表+红黑树,默认值增加了树的相关默认值,Entry类改成Node但是类里面内容没有改变。



            并且增添了TreeNode节点,继承自LinkedHashMap,LinkedHashMap则继承自HashMap。下面是TreeNode内容:


             红黑树是一种自平衡二叉查找树。它的统计性能要好于平衡二叉树(AVL树)。这种树结构从根节点开始,左子节点小于它,右子节点大于它。每个节点都符合这个特性,所以易于查找,是一种很好的数据结构。但是它有一个问题,就是容易偏向某一侧,这样就像一个链表结构了,失去了树结构的优点,查找时间会变坏。(这里就详细讲解这个结构了以后会开一个数据结构的专栏)

       阅读源码可以看出树比链表占了更多的空间。8后,决定如何使用这两种数据结构呢? 
           1、如果一个内部表的索引超过8个节点,链表会转化为红黑树 

           2、 如果内部表的索引少于6个节点,树会变回链表

3、HashMap的工作原理

            它的原理就是Hashing原理。在上一部分我们讲到了HashMap的数组结构,每添加(put())或是获取(get())都是向每个数组位(array[i])对应为一个bucket里的Entry中添加键值对。而通过hashCode()得出一个hash值来算出bucket(水桶)的位置来进行存取(这里的运算都是位运算)。


4、HashMap的存取

  1、存入主要是put()方法:                

            可以从上面源码看出在put时,根据hash值来确定存放数组的索引位置。如果该位置上已经存放了Entry那么,那么将会以链表的形式存放键值对,原来的Entry放在next进行链接,及新加入的放链头,原来的放链尾。如果该位置上没有Entry则直接存放到对应的数组索引的位置上。

 2、取出get()方法:


        从源码上可以看出get()也是先算取key的hash值,然后找到hash值对应的索引,看是否有链表,若有则看是否和链表中Entry的key是否相等,相等则equals()取value值。实际上就是将key-value看成一个整体Entry,来用hashCode()来查找hash值,如果有链表就判断链表,没有链表就直接取对应的数组索引。

 3、扩容resize()(rehash)方法:


5、HashMap的一些问题思考

1、什么时候会出现扩容呢?

    

        源码上addEntry是size大于threshold时开始调用resize()方法。也就是HashMap中,数组元素超过了数组阀值的时候就重新扩容,以降低实际的负载因子。(threshold>capacity*loadfactor)默认的的负载因子 0.75是对空间和时间效率的一个平衡选择。当容量超出此最大容量时, resize后的HashMap 容量是原容量的两倍。

2、HashMap容量一定为2的幂呢?


            首先,我们希望元素存放的更均匀,最理想的效果是每个bucket中存放一个Entry(key-value)。这样查询的时候效率高,不需要遍历链表,也不需要equals去比较链表中的key的内容。而且,这样空间利用率最大,时间复杂度最优。因为HashMap的底层数组的长度为2^n次方,不同的key算得的index的相同几率较小,数组上分布就比较均匀,也就是碰撞几率小,相对查询时效率会高。

            假设:数组长度为32时,即为2的n次方时,2n-1得到的二进制数的每个位上的值都为1,这使得在低位上&时,得到的和原hash的低位相同,加之hash(int h)方法对key的hashCode的进一步优化,加入了高位计算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表。

3、Hash冲突(碰撞)及解决Hash冲突的方法?        

             hash冲突就是当hash值相同,此时他们确定的索引位置相同,这时他们的key如果不相同,则为hash冲突。

            解决hash冲突的办法:

                    通常有:1、开放定址法 (基本思想是通过一个探测算法,当某个槽位已经被占据的情况下据需查找下一个可以使用的槽位)。 

                                  2、再哈希法(基本思想是同事构造多个哈希函数,当函数1冲突时,再用下一个方法计算,直到冲突不再产生)。这种方法不已长生聚焦,但增加计算时间。                                               

                                  3、链地址法(基本思想是将相同的hash值的对象组成一个成为同义词链的单链表,并将单链表的头指针存在哈希表的这个hash值中)。适用于经常插入和删除。

                                  4、建立公共溢出区(将哈希表分为基本表和溢出表两部分,凡是发生冲突的都放在溢出表中)。

                Java.until.HashMap就是采用链表法的方式。当发生碰撞,对象将会存储在LinkedList的下一结点中。HashMap在每个LinkedList节点中存储键值对对象。

4、重新调整HashMap大小存在的问题 

          HashMap数组扩容后很消耗性能:原数组中的数据必须重新计算其出现在新数组中的位置,并存储进去。

         在多线程下,HashMap扩容后可能会产生条件竞争(race condition)。如果两个线程都发现HashMap需要重新调整大小,他们会同时试着调整大小,在调整大小的过程中,存储在LinkedList中的元素次序会反过来,因为移动到新的bucket位置的时候Hashmap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争产生,就会出现死循环

5、Fail-First机制 

   java.util.HashMap 不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了 hashmap,那么将抛出 ConcurrentModificationException,这就是所谓 fail-fast 策略。 

       在迭代过程中,判断 modCount 跟 expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了 Map,则会抛出异常,下图是源代码: 


  解决办法:

        1、在遍历过程中所有涉及到改变modCount值得地方全部加上synchronized或者直接使用Collections.synchronizedList,这样就可以解决。但是不推荐,因为增删造成的同步锁可能会阻塞遍历操作。          

        2、使用CopyOnWriteArrayList来替换ArrayList。推荐使用该方案。

6、为什么String, Interger这样的wrapper类适合作为键?

           因为他们由final修饰,使用不可变类就能保证hashCode是不变的,而且重写了equals和hashcode方法,避免了键值对改写。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,提高HashMap性能。

        

猜你喜欢

转载自blog.csdn.net/xiaoxiaovbb/article/details/79895837