hashmap源码分析

hashmap是集合中一个重要的类,在平时的开发中也经常使用到,在面试中hashmap相关的题目也经常被问到,下面系统分析一下hashmap的底层结构。


实现原理

  • HashMap的底层实现是一个哈希表即数组+链表;
  • HashMap初始容量大小16,扩容因子为0.75,扩容倍数为2;
  • HashMap本质是一个一定长度的数组,数组中存放的是链表
    在这里插入图片描述

当向HashMap中put(key,value)时,会首先通过hash算法计算出存放到数组中的位置,比如位置索引为i,将其放入到Entry[i]中,如果这个位置上面已经有元素了,那么就将新加入的元素放在链表的头上,最先加入的元素在链表尾。比如,第一个键值对A进来,通过计算其key的hash得到的index=0,记做:Entry[0] = A。一会后又进来一个键值对B,通过计算其index也等于0,现在怎么办?HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;这样我们发现index=0的地方其实存取了A,B,C三个键值对,他们通过next这个属性链接在一起,也就是说数组中存储的是最后插入的元素。
在这里插入图片描述

HashMap的get(key)方法是:首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么hashmap的get效率将是最高的。所以我们需要让这个hash算法尽可能的将元素平均的放在数组中每个位置上。


put方法的实现

HashMap的数据结构为数组+链表,以key,value的形式存值,通过调用put与get方法来存值与取值。它内部维护了一个Entry数组,得到key的hashCode值将其移位按位与运算,然后再通过跟数组的长度-1作逻辑与运算得到一个index值来确定数据存储在Entry数组当中的位置,通过链表来解决hash冲突问题。当发生碰撞了,对象将会储存在链表的下一个节点中。

在这里插入图片描述
put方法

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
        //当首次调用put方法时,HashMap会发现table为空然后调用resize方法进行初始化
            n = (tab = resize()).length;
       //插入元素的hash值是一个32位的int值,而实际当前元素插入table的索引的值为 :(table.size - 1)& hash  
       又由于table的大小一直是2的倍数,2的N次方,因此当前元素插入table的索引的值为其hash值的后N位组成的值
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //当添加完元素后,如果HashMap发现size(元素总数)大于threshold(阈值),则会调用resize方法进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

简化版

public V put(K key, V value) {
       if (key == null)
           return putForNullKey(value);
       int hash = hash(key);
       int i = indexFor(hash, table.length);
       for (Entry<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;
           }
       }

       modCount++;
       addEntry(hash, key, value, i);
       return null;
   }

HashMap中put方法的过程?

答:“调用哈希函数获取Key对应的hash值,再计算其数组下标;
如果没有出现哈希冲突,则直接放入数组;如果出现哈希冲突,则以链表的方式放在链表后面;
如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表;
如果结点的key已经存在,则替换其value即可;
如果集合中的键值对大于12,调用resize方法进行数组扩容。”


get方法的实现

Hashmap的get方法是计算出key的hashcode找到对应的entry,这个时间复杂度为O(1),然后通过对entry中存放的元素key进行equal比较,找出元素,这个的时间复杂度为O(m),m为entry的长度。

// hashmap的get方法
public V get(Object key) {
	// 判断key 是否为null
      if (key == null)
      	// 如果为null 获取key为null的值
          return getForNullKey();
      // 获取对应key为要查询的key的entry
      Entry<K,V> entry = getEntry(key);
      // 判断是否获取到entry,如果没有,返回null,如果不为null,返回对应entry的value值
      return null == entry ? null : entry.getValue();
  }

  // 当key为null时获取value的值
  private V getForNullKey() {
  	// 判断hashmap中总的entry的数量,如果为0,说明hashmap中还没有值,返回null
      if (size == 0) {
          return null;
      }
      // 如果size 不为0 , 获取entry[] 数组中 下标为0的位置的链表
      for (Entry<K,V> e = table[0]; e != null; e = e.next) {
      	// 如果有entry对应的key的值为null ,返回对应的value
          if (e.key == null)
              return e.value;
      }
    	// 如果没有,返回空
      return null;
  }

  // 如果key不为null,获取key对应的value
  final Entry<K,V> getEntry(Object key) {
  	// 如果key不为null,判断hashmap中entry的数量是否为0 如果为0 返回null
      if (size == 0) {
          return null;
      }

      // 获取key的value值,如果key为null,返回hash值为0,反之,计算key对应的hash值
      int hash = (key == null) ? 0 : hash(key);
      // 遍历指定下标的entry数组元素链表
      for (Entry<K,V> e = table[indexFor(hash, table.length)];
           e != null;
           e = e.next) {
          Object k;
      	// 判断key的hash值与entry中的hash值是否相同,并且key通过== 和 equal 比较,
      	// 都为true时,返回这个 entry 对象 
          if (e.hash == hash &&
              ((k = e.key) == key || (key != null && key.equals(k))))
              return e;
      }
      // 如果指定下标key中的entry没有满足条件的,返回null
      return null;
  }

  // 计算 hash值
  final int hash(Object k) {
      int h = hashSeed;
      if (0 != h && k instanceof String) {
          return sun.misc.Hashing.stringHash32((String) k);
      }
      h ^= k.hashCode();
      h ^= (h >>> 20) ^ (h >>> 12);
      return h ^ (h >>> 7) ^ (h >>> 4);
  }

  // 通过hash值以及数组长度的位运算,获取entry的下标
  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);
  }

简化版

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    int hash = hash(key.hashCode());
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
         Object k;
         if (e.hash == hash && ((k = e.key) == key || key.equals(k))) 
             return e.value;
    }
    return null;
  }
 

扩容机制(resize方法的实现)

当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // HashMap初始容量大小(16) 
static final int MAXIMUM_CAPACITY = 1 << 30; // HashMap最大容量
transient int size; // The number of key-value mappings contained in this map
static f inal float DEFAULT_LOAD_FACTOR = 0.75f; // 负载因子
HashMap的容量size乘以负载因子[默认0.75] = threshold; // threshold即为开始扩容的临界值
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; // HashMap的基本构成Entry数组

  当HashMap中的元素个数超过数组大小(数组总大小length,不是数组中个数size)XloadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过16X0.75=12(这个值就是代码中的threshold值,也叫做临界值)的时候,就把数组的大小扩展为 2X16=32,即扩大一倍,然后重新计算每个元素在数组中的位置。
  0.75这个值成为负载因子,那么为什么负载因子为0.75呢?这是通过大量实验统计得出来的,如果过小,比如0.5,那么当存放的元素超过一半时就进行扩容,会造成资源的浪费;如果过大,比如1,那么当元素满的时候才进行扩容,会使get,put操作的碰撞几率增加。

数组扩容的过程

创建一个新的数组,其容量为旧数组的两倍,并重新计算旧数组中结点的存储位置。结点在新数组中的位置只有两种,原下标位置或原下标+旧数组的大小。
HashMap中扩容是调用resize()方法,方法源码:

void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//如果当前的数组长度已经达到最大值,则不在进行调整
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//根据传入参数的长度定义新的数组
Entry[] newTable = new Entry[newCapacity];
//按照新的规则,将旧数组中的元素转移到新数组中
transfer(newTable);
table = newTable;
//更新临界值
threshold = (int)(newCapacity * loadFactor);
}
//旧数组中元素往新数组中迁移
void transfer(Entry[] newTable) {
//旧数组
Entry[] src = table;
//新数组长度
int newCapacity = newTable.length;
//遍历旧数组
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);//放在新数组中的index位置
e.next = newTable[i];//实现链表结构,新加入的放在链头,之前的的数据放在链尾
newTable[i] = e;
e = next;
} while (e != null);
}
}
}

可以看到HashMap不是无限扩容的,当达到了实现预定的MAXIMUM_CAPACITY,就不再进行扩容。


hash方法的实现(扰动函数)

//重新计算哈希值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//key如果是null 新hashcode是0 否则 计算新的hashcode
}
  • ^按位异或运算,只要位不同结果为1,不然结果为0;
  • 程序中>>>表示无符号右移:左边补0

在这里插入图片描述

  将h无符号右移16为相当于将高区16位移动到了低区的16位,再与原hashcode做异或运算,可以将高低位二进制特征混合起来
从上文可知高区的16位与原hashcode相比没有发生变化,低区的16位发生了变化
  我们可知通过上面(h = key.hashCode()) ^ (h >>> 16)进行运算可以把高区与低区的二进制特征混合到低区,那么为什么要这么做呢?
  我们都知道重新计算出的新哈希值在后面将会参与hashmap中数组槽位的计算,计算公式:(n - 1) & hash,假如这时数组槽位有16个,则槽位计算如下:

在这里插入图片描述
  仔细观察上文不难发现,高区的16位很有可能会被数组槽位数的二进制码锁屏蔽,如果我们不做刚才移位异或运算,那么在计算槽位时将丢失高区特征
  也许你可能会说,即使丢失了高区特征不同hashcode也可以计算出不同的槽位来,但是细想当两个哈希码很接近时,那么这高区的一点点差异就可能导致一次哈希碰撞,所以这也是将性能做到极致的一种体现。

使用异或运算的原因

  异或运算能更好的保留各部分的特征,如果采用&运算计算出来的值会向1靠拢,采用|运算计算出来的值会向0靠拢。保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变。尽可能的减少碰撞。

为什么槽位数必须使用2^n

为了让哈希后的结果更加均匀
这个原因我们继续用上面的例子来说明
假如槽位数不是16,而是17,则槽位计算公式变成:(17 - 1) & hash

在这里插入图片描述
从上文可以看出,计算结果将会大大趋同,hashcode参加&运算后被更多位的0屏蔽,计算结果只剩下两种0和16,这对于hashmap来说是一种灾难。


indexFor方法的实现(MAP长度为啥是二次幂)

  我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。
  所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java中时这样做的,

static int indexFor(int h, int length) {  
       return h & (length-1);  
 } 

  首先算得key得hashcode值,然后跟数组的长度-1做一次“与”运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是2的4次方,那么hashcode就会和2的4次方-1做“与”运算。很多人都有这个疑问,为什么hashmap的数组初始化大小都是2的次方大小时,hashmap的效率最高,我以2的4次方举例,来解释一下为什么数组大小为2的幂时hashmap访问的性能最高。
  看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!

在这里插入图片描述
  所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
  说到这里,我们再回头看一下hashmap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面annegu的解释之后我们就清楚了吧,显然是因为16是2的整数次幂的原因,在小数据量的情况下16比15和20更能减少key之间的碰撞,而加快查询的效率。
   所以,在存储大容量数据的时候,最好预先指定hashmap的size为2的整数次幂次方。就算不指定的话,也会以大于且最接近指定值大小的2次幂来初始化的,代码如下(HashMap的构造方法中):

// Find a power of 2 >= initialCapacity  
        int capacity = 1;  
        while (capacity < initialCapacity)   
            capacity <<= 1; 

hashcode方法的作用

hashCode值是用来对HashMap数组的位置进行定位,如果向HashMap里存一个数,单纯的依次使用equals方法比较key是否相同来确定当前数据是否已存储过,那效率非常低,而通过比较hashCode值,效率就会大大提高,那它是如何定位数组位置呢,如果你使用的是jdk 1.8,那在put方法中的putVal方法里会看到如下内容:

public V put(K key, V value) { 
return putVal(hash(key), key, value, false, true); 
}

然后再点进putVal 方法,则会看到有下面的代码:
tab[i = (n - 1) & hash]

  对于上边的n-1后边会说到,先说一下上边的写法,&为二进制中的与运算,它的运算特点是,两个数进行&,如果都为1,则运算结果为1,否则为0。
  因为hashMap的数组长度都是2的n次幂,那么对于这个数再减去1,转换成二进制的话,就肯定是最高位为0,其他位全是1的数,那以数组长度为8为例(默认HashMap初始数组长度是16),那8-1转成二进制的话,就是0111。
  你可以随便变hashcode值来测试,最终得到的数都会小于8,当然会像上边一样,出现相同的数据,那样的话,就会以链表的形式存在那个数组元素上了。
  回过头来再设想一下,那我们就可以通过把key转为hashCode值,然后与数组长度进行与运算,来确定HahsMap中当前key对应的数据在数组中的位置。
  原本,需要查找数组下的每个元素,以及他们对应的链,疯狂的调用equals方法,誓死遍历出数据的方式,变成了仅仅是查找数组下的一个元素,然后只需要equals 比较这一条链上的数据就可以了,这样equals 的使用次数降低了很多。


HashMap的工作原理

HashMap 底层是 hash 数组和单向链表实现,数组中的每个元素都是链表,由 Node 内部类(实现 Map.Entry接口)实现,HashMap 通过 put & get 方法存储和获取。
存储对象时,将 K/V 键值传给 put() 方法:
①、调用 hash(K) 方法计算 K 的 hash 值,然后结合数组长度,计算得数组下标;
②、调整数组大小(当容器中的元素个数大于 capacity * loadfactor 时,容器会进行扩容resize 为 2n);
③、i.如果 K 的 hash 值在 HashMap 中不存在,则执行插入,若存在,则发生碰撞;
ii.如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 true,则更新键值对;
iii. 如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式)。(JDK 1.7 之前使用头插法、JDK 1.8 使用尾插法)(注意:当碰撞导致链表大于 TREEIFY_THRESHOLD = 8 时,就把链表转换成红黑树)

获取对象时,将 K 传给 get() 方法:
①、调用 hash(K) 方法(计算 K 的 hash 值)从而获取该键值所在链表的数组下标;
②、顺序遍历链表,equals()方法查找相同 Node 链表中 K 值对应的 V 值。hashCode 是定位的,存储位置;equals是定性的,比较两者是否相等。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值