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扩容例图

原文地址:https://www.cnblogs.com/xiaocao123/p/9681573.html