HashMap分析

107 阅读13分钟

什么是Hash

Hash是把任意长度的输入通过散列算法变换成固定长度的输出,该输出就是散列(hash)值。如下图所示:

在这里插入图片描述

一个好的散列算法应该包括一下两点:

散列后唯一性高

散列密度大

既然说到hash,肯定对应一个老生常谈的问题:什么是hash碰撞?

简单来讲,如果给定两个不同的值,通过散列算法计算后生成的hash值相同,即hash碰撞。一般情况下,哈希值越长的哈希算法,hash碰撞的概率越低。

问题

假设有以下需求:

给定5个数(大小在1-100之间)分别是10、50、60、1、5,现在要求快速判断7在不在这几个数中,不允许使用现有的数据结构。

1.可以使用简单的for循环进行判断,相等则返回存在,如果循环到末尾都没有,则说明不在其中。时间复杂度为O(N),N表示给定数的个数。

2.换一种思路

//新建一个数组,数组大小为100,这个时候数组所有的值应该默认都为0 
int a[] = new inta[100]; 
//我们将a数组中10、50、60、1、5下标对应的值改为1 
a[10] = 1; a[50] = 1; a[60] = 1; a[1] = 1; a[5] = 1; 
//这个时候要判断7在不在给定的这几个数中,只需要判断a[7]是否等于1 
if(a[7] == 1){ 
    System.out.println("存在"); 
    }else{ 
        System.out.println("不存在"); 
    }

上面方式2的时间复杂度是O(1),所以比较下来方式2比较快。但是这里面却有个很大的问题,那就是浪费了很多的空间(明明只有5个数,却开辟了100的空间),所以这种方式并不可取,那怎么办呢?

分析一下上面方式2的问题,问题的关键在于数组分配空间时,无用空间太多了,所以我们现在只给这个数组大小为10。当数组大小为10时,这时候又会有下一个问题,按照方式2的方式行不通了,为什么呢?a[50]明显越界了啊,这还怎么玩!所以可以使用取模运算进行代替:

使用将要放进数组的值对10进行取模,得到的余数为数组的索引,索引位置对应的值即将要放进数组的值。

假设现在的5个数分别为10、52、63、1、5,则如下进行存储:

//新建一个数组,数组大小为10,这个时候数组所有的值应该默认都为0 
int a[] = new inta[10]; 
//用10对10进行取余数得0,并将a[0]的值设置为10 
10%10 = 0 即 a[0] = 10; 
52%10 = 2 即 a[2] = 52; 
63%10 = 3 即 a[3] = 63; 
1%10 = 1 即 a[1] = 1; 
5%10 = 5 即 a[5] = 5;

很显然,我们要判断7是否存在这5个数中,只需要判断7%10 = 7即a[7]等于多少,如果不为0则表示存在,如果为0则表示不存在。

我们好像已经解决问题了,但又有一些新的麻烦,假设还有一个数为20,则20%10 = 0即a[0] = 20,但是在上面a[0]已经等于10了,这就完蛋了(你也可以将这个理解为碰撞)。

虽然上面的例子有些简陋,并且存在很多问题,但我们通过这些例子已经能够预见HashMap存储的原型了,下面正式进入主题。

成员变量

在这里插入图片描述

上面是HashMap中的主要成员变量,其中我们需要重点关注的是DEFAULT_INITIAL_CAPACITY、DEFAULT_LOAD_FACTOR、loadFactor、size、table、threshold。

·DEFAULT_INITIAL_CAPACITY

表示HashMap中桶的默认容量(1 << 4 == 16 )

·loadFactor

装载因子,用来表示HashMap中满的程度,其默认值为0.75f。

DEFAULT_LOAD_FACTOR

装载因子的默认值,为0.75f。

·size

表示HashMap元素的个数(这里的元素表示实际存储了多少个)

·table

桶,存放数组的地方,请注意这个桶数组的对象为Node,至于为什么要这样来存储,看下文就知道了。

·threshold

阈值,表示当HashMap中世纪存储元素的个数即size超过这个值时,需要对HashMap进行扩容。threshold=size * loadFactor。

接下来我们就来看看桶的每一个元素Node类。

Node类

我想大家在网上应该看到过这张图:

在这里插入图片描述

我们将上图中bucket数组称作桶,即成员变量中的table,其中每一个节点在HashMap中表示为Node。

HashMap是基于hash的,所以必然会产生上文所说的hash碰撞,那么怎么解决呢?解决这个问题的方法有很多种,例如开放定址法、再哈希法、链地址法等,HashMap所使用的就是链地址法。

链地址法的基本思想是:每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这也是HashMap所采用的。不过在这基础之上,JDK1.8之后,增加了红黑树,解决当冲突变多时查询效率变慢的问题(为什么会变慢?单向链表查询时需要不停的寻址,你细品)。

接下来我们就看看HashMap为我们准备的节点对象吧。

static class Node<K,V> implements Map.Entry<K,V> { 
    final int hash;//当前节点的hash值 
    final K key;//当前节点的key 
    V value;//当前节点的value Node<K,V> next;//下一个节点的引用,当不存在冲突时,下一个节点就是空的 
    
    Node(int hash, K key, V value, Node<K,V> next) { 
        this.hash = hash; 
        this.key = key; 
        this.value = value; 
        this.next = next; 
 } 

    public final K getKey() { return key; } 
    public final V getValue() { return value; } 
    public final String toString() { return key + "=" + value; }
    
    public final int hashCode() { 
    return Objects.hashCode(key) ^ Objects.hashCode(value); 
 } 
 
    public final V setValue(V newValue) { 
    V oldValue = value; 
    value = newValue; 
    return oldValue; 
 } 
    ...... 
}

上面的代码块定义了HashMap的桶中每一个节点的构造。这个类中的hash用来表示当前节点的hash值,还有一个属性next,指向下一个链表节点的引用。当发生冲突时,则将要存入HashMap的键值对对象追加至next。

构造方法

我们平时最长用的就是最简单的一种方式:

public HashMap() { 
    this.loadFactor = DEFAULT_LOAD_FACTOR; // 将加载因子设置为默认值 
}

这种方式下,loadFactor被设定为默认值即0.75f,接着其他的成员变量都初始化为默认值。接下来我们就从put说起吧。

添加

添加一个键值对的方式如下:

public V put(K key, V value) { 
    //调用hash方法,计算出key对应的hash值 
    return putVal(hash(key), key, value, false, true); 
} 

static final int hash(Object key) { 
    int h; 
    //将该对象的hash值异或上该值无符号右移16位的值(即自己的低8位异或上自己的高8位)
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }

上面添加元素的方法分为两步:

1.调用hash方法计算出该key对应的hash值

2.接着调用putVal方法保存键值对

步骤一:

这个方法即返回传入key对应的类的hashCode方法的计算结果。假设传入key对应类的hashCode方法实现的很好(碰撞的几率很小),实际上都不需要去异或(h >>> 16)。那为什么还要异或呢?要讲清楚这个问题,需要看看HashMap在存放键值对的时候是怎么操作的。HashMap计算键值对存放在桶中的位置时,使用了如下语句(n-1) & hash,其中n表示桶的大小,hash表示传入key的hash值。

以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值

在这里插入图片描述

无论你的hash值对应的hashCode算法实现的多么好,在桶长度16的情况下,始终只有D0~D3位被保存下来。

那又是为什么需要这样来取下标呢?可以看到,hashCode方法返回的hash值是一个int值,理论上来说,如果设计的很合理,那么碰撞的几率是很小的, int的范围为-21474836482147483647[-2^312^31-1]。这么大的数虽然不重复,但是当下标也不行啊,况且数组也开辟不了这么大的空间。所以就有了以下语句:

(n-1) & hash

这里有一个比较重要的定义:当n的值(被除数)等于2的n次方时,则 hash % n就等于 (n-1) &hash。

举例说明一下:

假设n=4,分别取hash值为11 12 15:

hash = 11 
            n-1 ---- 0011 & hash --- 1011 --------------------- 0011 结果为3 
hash = 12 
            n-1 ---- 0011 & hash --- 1100 --------------------- 0000 结果为0 
hash = 15 
            n-1 ---- 0011 & hash --- 1111 --------------------- 0011 结果为3

因为n必为2^n,所以自n-1的最高位往前数,全部都是n的倍数(eg:n为4,那么n-1为3,二进制表示为0011,从最高位往前数一个数即100,这个数很明显是4,再往前数一个数即1000,这个数就是8了。),计算余数肯定必须要把整数倍全部减掉剩下的才是余数啊,又因为n-1全部为1,所以能将除n的整数倍剩下余数完全保留下来。

这也是扩容时都会扩容为2的整数倍大小的原因。

接着往下应该就是最重要的putVal方法了:

/** * Implements Map.put and related methods. 
* 
* @param hash key对应的hash值 
* @param key 键 
* @param value 值 
* @param onlyIfAbsent 如果为true,当添加的key,value存在重复时,不覆盖原有的值 
* @param evict 如果为false,则说明table正在创建中,这个值一般在反序列化时用到 
*/ 
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { 
    //首先声明几个变量 
    //tab用于临时桶存放,p用于保存HashMap中对应key存在的情况下的节点引用 //n表示桶的长度,i表示key对应的hash值在经过取模运算后的值 
    Node<K,V>[] tab; Node<K,V> p; int n, i; 
    //1、当桶为null或桶的长度为0时,直接调用resize方法 
    if ((tab = table) == null || (n = tab.length) == 0) 
    n = (tab = resize()).length; 
    //2、当桶中对应位置为空时,直接放入就好 
    if ((p = tab[i = (n - 1) & hash]) == null) 
    tab[i] = newNode(hash, key, value, null); 
    else { 
        //3、桶中对应位置不为空,说明存在碰撞 
        Node<K,V> e; K k; //
        4、当前桶中的元素的hash值和将要存入的key的hash值相等,
        //且key是相等的------地址相等或equals方法执行相等 
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) 
        //将当前桶中该位置元素保存下来 
        e = p; 
        else if (p instanceof TreeNode) 
        //5、当前桶中元素为红黑树节点时,使用红黑树保存将要插入的元素 
        e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 
        else { 
        //6、当前桶中元素为链表节点时,使用单向链表保存将要插入的元素 
        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 
        //7、将链表转换为红黑树 
        treeifyBin(tab, hash); 
        break; 
      } 
      //8、和步骤4相同,只是判断链表中间节点是不是和将要插入的元素完全相同, 
      //步骤4是判断链表头节点是不是和将要插入的元素完全相同 
      if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) 
      break; 
     p = e; 
     } 
  } 
  //9、如果e不为空,说明将要插入的元素在链表中存在重复, 
  if (e != null) { // existing mapping for key 
      V oldValue = e.value; 
      if (!onlyIfAbsent || oldValue == null) 
          e.value = value; 
      afterNodeAccess(e); 
      return oldValue; 
   } 
 } 
 ++modCount; 
 if (++size > threshold) 
     resize(); 
 afterNodeInsertion(evict); 
 return null; 
}

上面的方法看起来稍微有点复杂,我们一步步来说:

1.当桶为null或桶的长度为0时,直接调用resize方法,这个方法稍后再讲,做的事情就是扩容了。

2.当桶中对应位置为空时,说明当前位置还没有数据,更不可能存在冲突,所以直接赋值。

3.当前桶中的元素的hash值和将要存入的key的hash值相等,且当前桶中的元素的key和将要存入的key相等(相等指:引用的地址相同或地址中存在的内容相同)时,将桶中对应位置的元素保存起来,这步其实就是判断当前桶对应位置的元素是不是完全和将要存入的元素相同,相同则说明不是冲突,有可能需要覆盖等操作。

4.当桶中对应位置的元素是树节点,则使用红黑树保存将要插入的元素。

5.当桶中对应位置的元素是链表节点,则开始循环链表,找到链表最后一个对象,将最后一个对象的next属性指向将要插入的元素(这里会提前将将要插入的元素new成一个Node节点对象),在这一步,还需要判断插入到链表尾部时是不是链表的长度已经大于等于8,如果是则需要转换为红黑树,提高查询效率。

6.在对HashMap做插入时,会传入一个onlyIfAbsent参数,默认值为false,说明是允许覆盖的,如果主动调用putIfAbsent,则不允许进行覆盖。

下面让我们看看如何进行扩容的。

扩容

final Node<K,V>[] resize() { 
    //保存老的桶 
    Node<K,V>[] oldTab = table; 
    //第一次时,oldCap为0,后面大于0 
    int oldCap = (oldTab == null) ? 0 : oldTab.length; 
    //保存老的阈值 
    int oldThr = threshold; 
    int newCap, newThr = 0; 
    //第一次不进这里,后面会进 
    if (oldCap > 0) { 
        //1、如果老的桶大小已经是最大容量了,直接返回 
        if (oldCap >= MAXIMUM_CAPACITY) { 
            threshold = Integer.MAX_VALUE; 
            return oldTab; 
        } 
        //2、将容量进行扩容为原来的两倍大小,阈值也扩容为原来的两倍大小 
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) 
            newThr = oldThr << 1; // double threshold 
} 
else if (oldThr > 0) // 3、老阈值大于0时直接将老阈值赋值给新容量 
    newCap = oldThr; 
else { // 4、第一次进这里,全部初始为默认值,即容量16,阈值12=16 * 0.75 
    newCap = DEFAULT_INITIAL_CAPACITY; 
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 
} 
//5、如果新容量等于0,这种情况是进入步骤3后,新阈值没有被赋值过 
if (newThr == 0) { 
    float ft = (float)newCap * loadFactor; 
    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); 
} 
//设置新阈值 
threshold = newThr; 
//然后new出来一个新数组,让table指向它 
@SuppressWarnings({"rawtypes","unchecked"}) 
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 
table = newTab; 
//下面就是简单的赋值操作了 
if (oldTab != null) { 
    for (int j = 0; j < oldCap; ++j) { 
        Node<K,V> e; 
        if ((e = oldTab[j]) != null) { 
            oldTab[j] = null; 
            if (e.next == null) 
                newTab[e.hash & (newCap - 1)] = e; 
            else if (e instanceof TreeNode)  
                ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 
            else { // preserve order 
                Node<K,V> loHead = null, loTail = null; 
                Node<K,V> hiHead = null, hiTail = null; 
                Node<K,V> next; 
                do { 
                    next = e.next; 
                    if ((e.hash & oldCap) == 0) { 
                        if (loTail == null) 
                            loHead = e; 
                        else 
                            loTail.next = e; 
                        loTail = e; 
                    } else { 
                        if (hiTail == null) 
                            hiHead = e; 
                        else 
                            hiTail.next = e; 
                        hiTail = e; 
                     } 
                 } while ((e = next) != null); 
                 if (loTail != null) { 
                     loTail.next = null; 
                     newTab[j] = loHead; 
                 } 
                 if (hiTail != null) { 
                     hiTail.next = null; 
                     newTab[j + oldCap] = hiHead; 
                 } 
             } 
          } 
       } 
    } 
    return newTab; 
}

其实扩容关键步骤就是上述代码中最后一个循环了,循环将元素进行遍历,是链表的按照链表进行存储,是红黑树节点的则按照红黑树进行保存。

通过put方法,可以总结出来HashMap的几个特性:

1,允许空键和空值(但空键只有一个,且放在第一位)。

2.当需要业务为不允许重复键时,需要重写键对应的类的hashCode方法和equals方法(String默认已经实现了)。

3.无序,而且在插入时可能顺序还会改变。

遍历

在说遍历之前,这里提一下删除方法,我们知道,HashMap是基于数组+链表+红黑树进行存储的。那么在删除的时候,除了需要判断数组和链表,还需要当删除的元素在红黑树节点时,旋转树以达到树的要求(具体细节涉及红黑树原理)。

方式一:

使用keySet进行遍历:

HashMap<String, Object> hashMap = new HashMap<>(); 
hashMap.put("张三", 123); 

Set<String> keySet = hashMap.keySet(); 
keySet.forEach(key -> System.out.println(hashMap.get(key)));

方式二:

使用entrySet进行遍历:

HashMap<String, Object> hashMap = new HashMap<>(); 
hashMap.put("张三", 123); 

Set<Entry<String, Object>> entrySet = hashMap.entrySet(); 
for (Entry<String, Object> entry : entrySet) { 
    System.out.println("key:" + entry.getKey() + ",value:" + entry.getValue()); 
}

方式三:

使用valueSet进行遍历:

HashMap<String, Object> hashMap = new HashMap<>(); 
hashMap.put("张三", 123); 

Collection<Object> values = hashMap.values(); values.forEach(value -> System.out.println(value));

以上这三种方式都是使用了HashMap中的内部类KeySet、EntrySet、Values。这些内部类会维持一个外部类的引用,所以可以去遍历HashMap。

方式四:

使用foreach:

HashMap<String, Object> hashMap = new HashMap<>(); 
hashMap.put("张三", 123); 

hashMap.forEach((key,value) -> System.out.println("key:" + key + ",value:" + value));

总结

本篇针对HashMap的原理展开,将HashMap中存放数据和遍历数据进行源码分析,了解其实现。在这之后,我们也可以继续探索本文未涉及到的删除、红黑树等方法和概念。

基于以上,我想大家应该对以下问题会有一定的看法了吧?

HashMap大小为什么是2的整数倍,这样做有什么好处?

HashMap的增加方法具体实现原理是什么样的?

HashMap在什么情况下会将单向链表转换为红黑树?