zoukankan      html  css  js  c++  java
  • HashMap的实现原理

     HashMap的底层实现原理

    参考如下博客:https://www.cnblogs.com/chengxiao/p/6059914.html#t1

    一、什么是哈希表

      在讨论哈希表之前,我们先大概了解下其他数据结构在新增,查找等基础操作执行性能

      数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)

      线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)

      二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。

      哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。

      我们知道,数据结构的物理存储结构只有两种:顺序存储结构链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组

      比如我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。

            存储位置 = f(关键字)

      其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:

      

      查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。

     哈希冲突

      然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式,

    二、HashMap实现原理

    HashMap中的几个属性:

     1     //1.定义table数组,存放hashMap元素,默认是没有初始化的(懒加载功能)
     2     Node<K,V>[] table = null;
     3 
     4     //2.实际存储的元素个数
     5      int size;
     6 
     7     //3.负载因子0.75(负载因子越小,hash冲突几率越小)
     8      float DEFAULT_LOAD_FACTOR = 0.75f;
     9 
    10      //4.table默认初始大小为16
    11     int DEFAULT_INITIAL_CAPACITY = 1 << 4; //相当于2^4=16

     Node是HashMap的一个静态内部类

     1      /**定义节点*/
     2     class Node<K,V> implements Entry<K,V>{
     3 
     4         /**存放map集合的key值*/
     5         private K key;
     6         /**存放map集合的value值*/
     7         private V value;
     8         /**下一个节点*/
     9         private Node<K,V> next;
    10 
    11         public Node(K key, V value, Node<K, V> next) {
    12             this.key = key;
    13             this.value = value;
    14             this.next = next;
    15         }

    Entry是一个接口,定义了HashMap的方法

     1 public interface ExaMap<K,V> {
     2 
     3     /**向集合中添加数据*/
     4     public V put(K k,V v);
     5 
     6     /**根据k从集合中查询value值*/
     7     public V get(K k);
     8 
     9     /**获取集合元素个数*/
    10     public int size();
    11 
    12     /**Entry接口的作用===Node节点*/
    13     interface Entry<K,V>{
    14         K getKey();
    15 
    16         V getValue();
    17 
    18         V setValue(V v);
    19     }

    所以,HashMap的整体结构如下

      

      简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

    1、接下来看看put方法的实现

    public V put(K key, V value) {
            /**1.判断table数组是否为空(如果为空的情况下,做初始化操作)*/
            if(table == null){
                table = new Node[DEFAULT_INITIAL_CAPACITY];
            }
    
            /**2.hashMap扩容机制(添加数组之前检查数组是否需要扩容) 为什么要扩容,有什么影响?hashMap从什么时候开始扩容*/
            if(size>(DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR)){
                //对table数组进行扩容
                resize();
            }
    
    
            /**3.计算hash值指定下标位置*/
            int index = getIndex(key,DEFAULT_INITIAL_CAPACITY);
            /**判断该索引下是否有node对象*/
            Node<K,V> node = table[index];
            if(node == null){
                /**表示没有发生hash冲突,创建一个Node对象*/
                node = new Node<K,V>(key,value,null);
                /**实际存储的元素个数+1*/
                size++;
                //return node.value;
            }else{
                /**发生hash冲突:
                 * jdk1.7包含如下设计原则:
                 * 系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处
                 * 如果 bucketIndex 索引处已经有了一个 Entry 对象,
                 * 那新添加的 Entry 对象指向原有的 Entry 对象(产生一个 Entry 链)。*/
    
                /**新添加的Entry对象总是放到数组的bucketIndex索引处*/
                Node<K,V> newNode = node;
    
                while(newNode != null){
                    //修改node对象的value值
                    if(newNode.getKey().equals(key) || newNode.getKey() == key){
                        //hashCode相等,而且equals相同,说明是一个对象;修改值
                        //node.value = value;
                        return newNode.setValue(value);
                    }else{
                        //添加node对象到链表中
                        if(newNode.next == null){
                            //说明遍历到了最后一个node,添加node
                            node = new Node<K,V>(key,value,node);
                            size++;
                        }
    
                    }
                    newNode = newNode.next;
                }
    
            }
            table[index] = node;
            return null;
        }

    HashMap在设计时遵循如下原则:系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处——如果 bucketIndex 索引处已经有了一个 Entry 对象,那新添加的 Entry 对象指向原有的 Entry 对象(产生一个 Entry 链),如果 bucketIndex 索引处没有 Entry 对象,也就是上面程序代码的 e 变量是 null,也就是新放入的 Entry 对象指向 null,也就是没有产生 Entry 链。向链表添加元素时,首先遍历链表。如果key的hashCode相同,key的equals()方法也相同,即是同一个元素,则修改值;否则执行添加操作。

    h&(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为

            1  0  0  1  0
        &   0  1  1  1  1
        __________________
            0  0  0  1  0    = 2

      最终计算出的index=2。有些版本的对于此处的计算会使用 取模运算,也能保证index一定在数组范围内,不过位运算对计算机来说,性能更高一些(HashMap中有大量位运算)

    所以最终存储位置的确定流程是这样的:

     

    2、HashMap的扩容方法

     1      /**对table数组进行扩容*/
     2     private void resize(){
     3         //1.生成新的table,长度是之前的2倍
     4        Node<K,V>[] newTable = new Node[DEFAULT_INITIAL_CAPACITY*2];
     5         //2.重新计算index的位置,存放在新的table里面
     6         for(int i=0;i<table.length;i++){
     7             //存放在之前的table,原来的node
     8            Node<K,V> oldNode = table[i];
     9            while(oldNode != null){ //遍历链表中的所有node节点
    10                table[i] = null; //赋值为null---为了垃圾回收机制可以回收(将之前的node删除)
    11                 //存放之前table中node的key
    12                 K oldKey = oldNode.key;
    13                 //重新计算index(新的位置)
    14                int newIndex = getIndex(oldKey,newTable.length);
    15                //原来node的下一个
    16                Node<K,V> oldNext = oldNode.next;
    17                //如果index下标在新的newTable发生相同的时候,以链表进行存储
    18                oldNode.next = newTable[newIndex];
    19                //将之前的node赋值给newTable[newIndex]
    20                newTable[newIndex] = oldNode;
    21                //判断是否继续进行循环
    22                oldNode = oldNext;
    23 
    24            }
    25         }
    26 
    27         //3.将newTable赋值给旧的table
    28         table = newTable;
    29         DEFAULT_INITIAL_CAPACITY = newTable.length;
    30         newTable = null;//垃圾回收机制回收
    31     }

     

     注意:在jdk1.7中数组扩容后在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处。如下图所示:

    3、为何HashMap的数组长度一定是2的次幂?

    众所周知,HashMap是基于Hash表的Map接口实现,HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。HashMap中主要是通过key的hashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。但是知道了Hash值之后,又是怎么确定出key在数组中的索引呢?

    根据源码得知如下方法
    static int indexFor(int h, int length) { //根据hash值和数组长度算出索引值
             return h & (length-1);  

    这样做有什么好处呢 ?

    3.1、保证不会发生数组越界

    首先我们要知道的是,在HashMap,数组的长度按规定一定是2的幂。因此,数组的长度的二进制形式是:10000…000, 1后面有偶数个0。 那么,length - 1 的二进制形式就是01111.111, 0后面有偶数个1。最高位是0, 和hash值相“与”,结果值一定不会比数组的长度值大,因此也就不会发生数组越界。

    3.2、保证元素尽可能的均匀分布

    由上边的分析可知,length一定是一个偶数,length - 1一定是一个奇数。假设现在数组的长度length为16,减去1后length - 1就是15,15对应的二进制是:1111。现在假设有两个元素需要插入,一个哈希值是8,二进制是1000,一个哈希值是9,二进制是1001。和1111“与”运算后,结果分别是1000和1001,它们被分配在了数组的不同位置,这样,哈希的分布非常均匀。那么,如果数组长度是奇数呢?减去1后length - 1就是偶数了,偶数对应的二进制最低位一定是 0,例如14二进制1110。对上面两个数子分别“与”运算,得到1000和1000。结果都是一样的值。那么,哈希值8和9的元素都被存储在数组同一个index位置的链表中。在操作的时候,链表中的元素越多,效率越低,因为要不停的对链表循环比较。

    按位与运算符(&)

    参加运算的两个数据,按二进制位进行“与”运算。

    运算规则:0&0=0;  0&1=0;   1&0=0;    1&1=1;

          即:两位同时为“1”,结果才为“1”,否则为0

    例如:3&5  即 0000 0011& 0000 0101 = 00000001  因此,3&5的值得1。

    元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

    只需要关注h的最高位是0还是1即可。如果是0,这在新数组中的位置不变;如果是1,则在新数组的位置为原位置+扩容的大小

    hashMap 1.8 哈希算法例图2

    因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

    jdk1.8 hashMap扩容例图

  • 相关阅读:
    Mybatis学习-ResultMap
    MySql模糊查询 concat()函数
    Spring学习-依赖注入
    Struts2学习-struts执行过程简述
    Struts2学习-jsp中超链接传参问题
    Struts2学习-struts.xml文件配置
    第四次作业
    第三次作业
    Django -Ajax
    Django -ORM
  • 原文地址:https://www.cnblogs.com/xiaocao123/p/9681573.html
Copyright © 2011-2022 走看看