转载

数据结构之哈希表 Java中的经典实现HashMap分析

HashMap是最常用的Map族中的一个,Java Collection Framework 重要成员之一,HashMap 在项目中最常用到,既然HashMap如此重要,更应该了解HashMap的数据结构、实现原理、源码分析以及p如何实现快速的存取和扩容。

本文关于HashMap的源码是基于 Android 7.0的源码,不同版本的jdk 源码也会存在一些差异,这些都不重要,重点在与我们主要了解HashMap的数据结构以及原理实现。

哈希表知识回顾

在上一篇文章中讲解了Hash表的基础知识

为什么会出现哈希表?

已知顺序表(数组),查找容易,插入、删除困难消耗性能。然而链表虽然解决了顺序表插入删除的问题,但是链表的查找却降低了性能。

结合两者的优缺点,于是便出现了,查找容易同时插入删除容易的表Hash表。

哈希表有什么特点?

优点:查找、插入、删除只需要接近敞亮的时间O(1),实际上就是几条及其指令,在速度和易用性上是非常棒的。

缺点:基于数组,数组创建后都是固定大小的,难于拓展,当hash表被填满后,性能下降很严重,所以要求预先清楚表中存储多少数据。

HashMap 概述

HashMap是基于Map接口实现的,以Key-Value的形式存在,存储了 static class HashMapEntry<K,V> implements Map.Entry<K,V> HashMapEntry 对象,同时HashMap会根据hash算法计算Key-Vaule的存储位置实现快速存取,HashMap是不支持并发操作的。

HashMap的数据结构

数据结构之哈希表 Java中的经典实现HashMap分析

每一个小块中都嵌套Node的实例

static class HashMapEntry<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    }

从上图可以看出,HashMap里有一个数组,并且数组中每一个元素都是一个单向链表。

HashMap hash冲突解决方案:拉链法。HashMap就是一个链表数组。

通过上图,可以推算出HashMap大致的实现原理:

HashMap 插入:通过hash函数 f(key)函数中有一个hash算法 来得到一个hash值,hash值的范围一定在数组范围之内,通过hash值找到数组对应的下标,然后在该数组的单向链表插入表头(为什么插入表头呢,如果插入链表尾端就需要循环链表,很明显会造成性能上的消耗,如果插入表头,就直接插入操作便可以了)。

HashMap 查找:通过hash函数 f(key)函数中有一个hash算法 来得到一个hash值,一般hash值就是数组的下标,但是还是需要判断看hash值是否超过了数组的长度,然后遍历该数组处的链表,直到 HashMapEntry.key == key

HashMap 删除:同样通过key得到一个hash值,找到数组的下标,然后遍历该数组处的链表,直到 HashMapEntry.key == key,然后就是链表的删除操作。

[图片上传失败…(image-ebe763-1532773587503)]

HashMap源码分析

AbstractMap 抽象类提供了 Map 接口的骨干实现,以最大限度地减少实现Map接口所需的工作

public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

HashMap的属性

/**
   * 
   * 当前数组的容量,默认是4个数组,必须是2的n次方,扩容后的大小为当前的两倍
   */
  static final int DEFAULT_INITIAL_CAPACITY = 4;

  /**
   * 在构造函数中没有指定时使用的默认负载因子。
   */
  static final float DEFAULT_LOAD_FACTOR = 0.75f;

  /**
   * 一个空的数组
   */
  static final HashMapEntry<?,?>[] EMPTY_TABLE = {};

  /**
   * HashMap的数组,该数组根据需要调整大小,大小始终是2的N次方
   */
  transient HashMapEntry<K,V>[] table = (HashMapEntry<K,V>[]) EMPTY_TABLE;

  /**
   * HashMap的大小
   */
  transient int size;

  /**
   * 扩容的阈值 (capacity * load factor).
   * @serial
   */
  // If table == EMPTY_TABLE then this is the initial capacity at which the
  // table will be created when inflated.
  int threshold;

  /**
   * 负载因子,默认为 0.75。
   *
   */
  // Android-Note: We always use a load factor of 0.75 and ignore any explicitly
  // selected values.
  final float loadFactor = DEFAULT_LOAD_FACTOR;
  
  /**
  * 最大容量
  */
  static final int MAXIMUM_CAPACITY = 1 << 30;

HashMap 构造函数

HashMap(int initialCapacity, float loadFactor) 指定初始容量和负载因子

HashMap(int initialCapacity) 指定初始容量

public HashMap(int initialCapacity, float loadFactor) {
       //初始容量不能小于 0 
       if (initialCapacity < 0)
           throw new IllegalArgumentException("Illegal initial capacity: " +
                                              initialCapacity);
       //初始容量不能超过 2^30
       if (initialCapacity > MAXIMUM_CAPACITY) {
           initialCapacity = MAXIMUM_CAPACITY;
       } else if (initialCapacity < DEFAULT_INITIAL_CAPACITY) {
           initialCapacity = DEFAULT_INITIAL_CAPACITY;
       }
       //负载因子不能小于0
       if (loadFactor <= 0 || Float.isNaN(loadFactor))
           throw new IllegalArgumentException("Illegal load factor: " +
                                              loadFactor);
                                              
       //--------注意这一段是 在Android的源码中--------------- 
       //Android中的源码并没有改变负载因子,负载因子始终为0.75
       // Android-Note: We always use the default load factor of 0.75f.

       // This might appear wrong but it's just awkward design. We always call
       // inflateTable() when table == EMPTY_TABLE. That method will take "threshold"
       // to mean "capacity" and then replace it with the real threshold (i.e, multiplied with
       // the load factor). 
       //阈值便是初始的容量
       threshold = initialCapacity;
       //----------------------------------------------------
       
       //--------注意这一段是在Java的源码中--------------------------
       // HashMap 的容量必须是2的幂次方,超过 initialCapacity 的最小 2^n 
       int capacity = 1;
       while (capacity < initialCapacity)
           capacity <<= 1;   

       //负载因子
       this.loadFactor = loadFactor;

       //设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行自动扩容操作
       threshold = (int)(capacity * loadFactor);

       // HashMap的底层实现仍是数组,只是数组的每一项都是一条链
       table = new Entry[capacity];
       //--------------------------------------------------------
       
       init();
   }

   
   public HashMap(int initialCapacity) {
       //指定容量大小
       this(initialCapacity, DEFAULT_LOAD_FACTOR);
   }
   
   public HashMap() {
       //使用默认值
       this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
   }
   
    public HashMap(Map<? extends K, ? extends V> m) {
       // 接收一个map m的大小/负载因子+1 如果小于 默认的容量大小则 使用默认的容量
       this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                     DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
       inflateTable(threshold);

       putAllForCreate(m);
   }
   
   init(){
       
   }

HashMap put过程分析,跟随源码走一遍

public V put(K key, V value) {
        //如果数组部分是一个空表,也可以说插入第一个元素的时候,需要先初始化数组大小
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        //如果key == null,最终会放到table[0]中
        if (key == null)
            return putForNullKey(value);
        // 计算key的hash值    
        int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key);
        //找到数组的下标,一般hash为数组的下标,同时要防止hash > table.length ,这时取table.length
        int i = indexFor(hash, table.length);
        
        //遍历一下对应下标处数组的链表,查看是否重复key的存在如果有则直接覆盖,到此put结束
        for (HashMapEntry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        //如果没有重复的key 则添加到链表中
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

上述代码中可以看出,源码的逻辑非常严谨,首先判断table是否是一个空表,如果是空表,初始化数组的大小,然后在判断key是否为null,如果为null,就放到table[0]对应的链表中,如果key不为null,就根据key计算hash值,然后得到数组的下标,再遍历对应下标处数组存储的链表,看是否存在重复的key,如果存在就替换掉,如果不存在就添加到链表的头节点。这就是一个完整的put过程。

标注:不同版本的Java SDK 可能计算的hash值的算法不同

//Java jdk 8
static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
    
    
 //android 源码中计算hash值的算法    
 int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key);

inflateTable 数组的初始化分析,我们看一下inflateTable的实现,代码如下:

private void inflateTable(int toSize) {
       // 保证数组的大小一定是 2^n
       int capacity = roundUpToPowerOf2(toSize);

       //计算扩容阈值 
       float thresholdFloat = capacity * loadFactor;
       if (thresholdFloat > MAXIMUM_CAPACITY + 1) {
           thresholdFloat = MAXIMUM_CAPACITY + 1;
       }
        threshold = (int) thresholdFloat;

       //初始化数组       
       table = new HashMapEntry[capacity];
   }
   
   //有兴趣可以了解这个方法的实现
    private static int roundUpToPowerOf2(int number) {
       // assert number >= 0 : "number must be non-negative";
       int rounded = number >= MAXIMUM_CAPACITY
               ? MAXIMUM_CAPACITY
               : (rounded = Integer.highestOneBit(number)) != 0
                   ? (Integer.bitCount(number) > 1) ? rounded << 1 : rounded
                   : 1;

       return rounded;
   }

indexFor 计算数组的具体位置

当length为2的n次方时,h&(length - 1)就相当于对length取模,而且速度比直接取模要快得多,这是HashMap在速度上的一个优化

//使用 key 的 hash 值对数组长度进行取模
static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

hash()方法和indexFor()方法的作用只有一个:保证元素均匀分布到table的数组中以便充分利用空间。

putForNullKey key == null 时的处理

private V putForNullKey(V value) {
        //遍历数组的第一个下标的链表,查找是否存在key == null,如果存在替换之前的数据,至此结束
        for (HashMapEntry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        //如果不存在添加到数组的第一个下标的链表中 
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

addEntry 添加节点到链表

有一点值得注意,HashMap 永远都是在链表的表头添加新元素。

void addEntry(int hash, K key, V value, int bucketIndex) {
     //当前HashMap的大小已经达到了阈值 并且要插入的数组的位置已经有了元素,那么需要扩容
     if ((size >= threshold) && (null != table[bucketIndex])) {
         //扩容之前的两倍
         resize(2 * table.length);
         //扩容后重新计算hash值
         hash = (null != key) ? sun.misc.Hashing.singleWordWangJenkinsHash(key) : 0;
         //重新计算下标
         bucketIndex = indexFor(hash, table.length);
     }
     //这个方法很简单 将新值放到链表的表头
     createEntry(hash, key, value, bucketIndex);
 }

 
 void createEntry(int hash, K key, V value, int bucketIndex) {
     HashMapEntry<K,V> e = table[bucketIndex];// 之前的链表
     table[bucketIndex] = new HashMapEntry<>(hash, key, value, e);//将新的节点作为表头
     size++;
 }

数组扩容resize

如果插入新值是HashMap的size 达到了阈值(length * 负载因子),并且要插入的数组上已经有了元素,那么就需要进行扩容,扩容后数组的大小为原来的两倍。这也是为了保证HashMap的效率,尽可能小的影响HashMap的存取速度

void resize(int newCapacity) {
        HashMapEntry[] oldTable = table;//旧的数组
        int oldCapacity = oldTable.length;//旧的数组的大小
        //如果旧的数组的大小>最大容量,那么将阈值设置为最大整数
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        //新的数组
        HashMapEntry[] newTable = new HashMapEntry[newCapacity];
        //将原来数组的值迁移到新的数组中
        transfer(newTable);
        table = newTable;
        //重新计算阈值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
    
    //将旧数组的值迁移到新数组上
    void transfer(HashMapEntry[] newTable) {
        int newCapacity = newTable.length;
        //遍历旧数组,将旧数组中的每个链表添加到新的数组中
        for (HashMapEntry<K,V> e : table) {
            while(null != e) {
                //将值进行迁移
                HashMapEntry<K,V> next = e.next;
                //数组的下标
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

扩容就是用新的大数组来代替原来的小数组,并将原来数组的值迁移到新数组中。

get过程分析,分析完put过程那么get很简单

原理:通过key,哈希函数计算得到hash值,然后通过hash值得到数组的下标,然后遍历该数组处的链表。

 public V get(Object key) {
        if (key == null)
            // 当key等于null时的处理,遍历table[0]的链表,找到key==null的节点。
            return getForNullKey();
        //通过key获取    
        HashMapEntry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }

//这个方法就是遍历 table[0] 中的链表。
private V getForNullKey() {
        if (size == 0) {
            return null;
        }
        for (HashMapEntry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }

getEntry获取节点

final Entry<K,V> getEntry(Object key) {
      //size==0 说明HashMap大小为0,返回null
      if (size == 0) {
          return null;
      }
      //如果key == null hash =0;否则就计算hash值
      int hash = (key == null) ? 0 : sun.misc.Hashing.singleWordWangJenkinsHash(key);
      //遍历对应数组下标的单向链表
      for (HashMapEntry<K,V> e = table[indexFor(hash, table.length)];
           e != null;
           e = e.next) {
          Object k;
          if (e.hash == hash &&
              ((k = e.key) == key || (key != null && key.equals(k))))
              return e;
      }
      return null;
  }

remove过程分析

实现过程同样也很简单,都需要得到hash值,可见hash值在哈希表中是非常重要的角色,通过hash值找到对应下标的链表,然后做链表删除操作。

public V remove(Object key) {
      Entry<K,V> e = removeEntryForKey(key);
      return (e == null ? null : e.getValue());
  }

removeEntryForKey 删除操作

final Entry<K,V> removeEntryForKey(Object key) {
       //同样需要判断HashMap大小是否为0
       if (size == 0) {
           return null;
       }
       //得到hash值
       int hash = (key == null) ? 0 : sun.misc.Hashing.singleWordWangJenkinsHash(key);
       //得到数组下标
       int i = indexFor(hash, table.length);
       //得到对应数组下标的链表
       HashMapEntry<K,V> prev = table[i];
       HashMapEntry<K,V> e = prev;
       //遍历链表找到对应的key
       while (e != null) {
           HashMapEntry<K,V> next = e.next;
           Object k;
           //链表中存在key,则删除这个节点
           if (e.hash == hash &&
               ((k = e.key) == key || (key != null && key.equals(k)))) {
               modCount++;
               size--;//HashMap大小减一
              
               if (prev == e)
                   table[i] = next; //删除头节点
               else
                   prev.next = next;//删除节点
               e.recordRemoval(this);
               return e;
           }
           prev = e;
           e = next;
       }

       return e;
   }

思考 HashMap为什么要求数组的长度必须为2的n次方呢?

当length = 2^n时

第一点:不同的hash值发生碰撞的几率比较小,这样就是数据在数组中分布均匀,空间利用率高查询速度快。

第二点:h&(length - 1) 就相当于对length取模,而且在速度、效率上比直接取模要快得多

参考链接:

Map 综述(一):彻头彻尾理解 HashMap

Java7/8 中的 HashMap 和 ConcurrentHashMap 全解析

数据结构系列:

数据结构之表的总结

数据结构之Java中哈希表的经典实现HashMap分析
原文  https://www.jakeprim.cn/2019/02/15/datastructure2/
正文到此结束
Loading...