Java集合之HashMap

一、HashMap概述

Map 是 Key-Value 对映射的抽象接口,该映射不包括重复的键,即一个键对应一个值。HashMap是基于哈希表的Map接口的同步实现,以Key-Value的形式存在。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。在HashMap中,其会根据hash算法来计算key-value的存储位置并进行快速存取。特别地,HashMap最多只允许一条Entry的键为Null(多条会覆盖),但允许多条Entry的值为Null。 

二、HashTable的数据结构

HashTable与Map的关系图


从图中可以看出: 

(01) HashMap继承于AbstractMap类,实现了Map接口。Map是"key-value键值对"接口,AbstractMap实现了"键值对"的通用函数接口。 

(02) HashMap是通过"拉链法"实现的哈希表。它包括几个重要的成员变量:tablesizethresholdloadFactormodCount
  table是一个Entry[]数组类型,而Entry实际上就是一个单向链表。哈希表的"key-value键值对"都是存储在Entry数组中的。 
  size是HashMap的大小,它是HashMap保存的键值对的数量。 
  threshold是HashMap的阈值,用于判断是否需要调整HashMap的容量。threshold的值="容量*加载因子",当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍。
  loadFactor就是加载因子。 

  modCount是用来实现fail-fast机制的。

HashTable的继承关系:

java.lang.Object
   ↳     java.util.Dictionary<K, V>
         ↳     java.util.Hashtable<K, V>

public class Hashtable<K,V> extends Dictionary<K,V>
    implements Map<K,V>, Cloneable, java.io.Serializable { }

HashMap的构造函数

// 默认构造函数。
HashMap()

// 指定“容量大小”的构造函数
HashMap(int capacity)

// 指定“容量大小”和“加载因子”的构造函数
HashMap(int capacity, float loadFactor)

// 包含“子Map”的构造函数
HashMap(Map<? extends K, ? extends V> map)

HashMap的数据结构

在Java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体


从上图中可以看出,HashMap底层就是一个数组结构,数组中的每一项又是一个单链表。当新建一个HashMap的时候,就会初始化一个Entry类型的table数组

static class Entry<K,V> implements Map.Entry<K,V> {

    final K key;     // 键值对的键
    V value;        // 键值对的值
    Entry<K,V> next;    // 下一个节点
    final int hash;     // hash(key.hashCode())方法的返回值

    /**
     * Creates new entry.
     */
    Entry(int h, K k, V v, Entry<K,V> n) {     // Entry 的构造函数
        value = v;
        next = n;
        key = k;
        hash = h;
    }

    ......

}

可以看出,Entry就是数组中的元素,每个 Map.Entry 其实就是一个key-value对,它持有一个指向下一个元素的引用,这就构成了链表。

三、HashMap源码分析

1.存储
public V put(K key, V value) {
    // HashMap允许存放null键和null值。
    // 当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。  
    if (key == null)
        return putForNullKey(value);
    // 根据key的keyCode重新计算hash值。
    int hash = hash(key.hashCode());
    // 搜索指定hash值在对应table中的索引。
    int i = indexFor(hash, table.length);
    // 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素。循环key所在的位置
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        //判断table[i]链条上是否存在hash值相同且key值相等的映射,若存在,用value覆盖oldvalue,并返回value
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    // 如果i索引处的Entry为null,表明此处还没有Entry。
    modCount++;
    // 将key、value添加到i索引处。
    addEntry(hash, key, value, i);
    return null;
}

从上面的源代码中可以看出:当我们往HashMap中put元素的时候,先根据key的hashCode重新计算hash值,根据hash值得到这个元素在数组中应该存放的位置(即下标), 如果数组该位置上已经有其他元素了,则查找是否存在相同的key,若存在则覆盖原来key对应的value,否则在这个位置上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。


2.HashMap 中键值对的添加
addEntry(hash, key, value, i)方法根据计算出的hash值,将key-value对放在数组table的i索引处。addEntry 是 HashMap 提供的一个包访问权限的方法,代码如下:
void addEntry(int hash, K key, V value, int bucketIndex) {
    // 获取bucketIndex 索引处的 Entry  
    Entry<K,V> e = table[bucketIndex];
    // 将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry  
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    // 如果 Map 中的 key-value 对的数量超过了threshold
    if (size++ >= threshold)
    // 把 table 对象的长度扩充到原来的2倍。
        resize(2 * table.length);
}

HashMap 总是将新的Entry对象添加到bucketIndex处,若bucketIndex处已经有了Entry对象,那么新添加的Entry对象将指向原有的Entry对象,并形成一条新的以它为链头的Entry链;但是,若bucketIndex处原先没有Entry对象,那么新添加的Entry对象将指向 null,也就生成了一条长度为 1 的全新的Entry链了。HashMap 永远都是在链表的表头添加新元素。此外,若HashMap中元素的个数超过极限值 threshold,其将进行扩容操作,一般情况下,容量将扩大至原来的两倍。

hash(int h)方法根据key的hashCode重新计算一次散列。此算法加入了高位计算,防止低位不变,高位变化时,造成的hash冲突。

static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);  
}
上面代码的意思借用知乎的回答:https://www.zhihu.com/question/51784530

这段代码是为了对key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突。

举例说明:
key1的hashCode是11280384,用16进制表示是0x0AC20000
key2的hash是1568768,用16进制表示是0x017F0000

如果不进行扰动,直接用hashCode来计算key在HashMap的table[]中的index:
static int indexFor(int h, int length) {
        return h & (length-1);  
    }

假设此时table[]的length是16,那么对于key1来说就是
0AC20000 & 0000000F = 0
对于key2来说
017F0000 & 0000000F = 0
也就是说key1和key2的存储位都是0,发生了hash冲突。

11280384和1568768这两个hashcode明显是完全不同的,但却发生了hash冲突,是因为这两个hashcode的低位相同,高位不同。而HashMap在计算index时只取低位,如果不对hashcode进行高低位扰动计算,就会极大增加了发生hash冲突的几率。

加入扰动函数之后:
0AC20000 >>> 20 = 000000AC
0000 1010 1100 0010 0000 0000 0000 0000 右移20位
= 0000 0000 0000 0000 0000 0000 1010 1100

0AC20000 >>> 12 = 0000AC20
000000AC ^ 0000AC20 = 0000AC8C
0AC20000 ^ 0000AC8C = 0AC2AC8C

看到这里应该明白了,这段扰动函数的目的是把hashcode的高低位混在一起,让高位发生变化时,低位同时也发生变化。
接下来又进行了一些更多的扰动计算:
0AC2AC8C >>> 7 = 00158559
0AC2AC8C >>> 4 = 00AC2AC8
0AC2AC8C ^ 00158559 ^ 00AC2AC8 = 0A7B031D
所以对于key1来说,扰动完成后的hash是0A7B031D

而key2进行相同的扰动计算后,hash是016A18B6
这样key1的存储位是13,key2是6,没有发生hash冲突。

题外话,在java8中这个扰动函数被简化了:
h = h ^ (h >>> 16)
只简单地把hashCode的高16位移动到低16位后与自身进行异或。


3.关于indexFor方法(为什么用&,并且和length-1作运算):
 /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);  
    }
int capacity = 1;
    while (capacity < initialCapacity)  
        capacity <<= 1;

普通的Hash散列算法都是h%length,但是HashMap用的是位运算&。这是因为HashMap的初始大小和扩容以2的次方进行的。(如初始容量为16,每次扩容容量是之前的2倍)。而length-1转换成二进制每一位都是1,如数组容量为16,则length-1为1111,(&运算的规则为1&1=1,1&0=0,0&1=0,0&0=0),也就是说length-1中的某一位为1,则对应位置的计算结果才取决于h中的对应位置(h中对应位取0,对应位结果为0,h对应位取1,对应位结果为1。这样就有两个结果),但是如果length-1中某一位为0,则不论h中对应位的数字为几,对应位结果都是0,这样就让两个h取到同一个结果,这就是hash冲突了,恰恰length-1又是全部为1的数,所以结果自然就将hash冲突最小化了。(冲突最小化,查询和存储的效率才会越高,内存利用率也越高)。h%length与h&(length-1)的结果一样,计算机中位运算的速度快于十进制运算,这是HashMap在速度上的优化。

 根据上面 put 方法的源代码可以看出,当试图将一个Entry放入HashMap中时,首先根据key 的 indexFor() 返回值决定该 Entry 的存储位置:如果两个 Entry 的 key 的 indexFor() 返回值相同,那它们的存储位置相同。此时通过equals()比较两个key是否相同,若相同则新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但key不会覆盖。如果两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部。

4.读取

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;
}
读取的过程和存储的思路基本相同。 从HashMap中get元素时,首先获得hash值,根据hash与table.length进行&运算找到Entry在数组中的位置,然后通过key的equals方法在对应位置的链表中找到所需的元素,若不存在返回null

5.HashMap的扩容:resize()

    随着HashMap中元素的数量越来越多,发生碰撞的概率将越来越大,所产生的子链长度就会越来越长,这样势必会影响HashMap的存取速度。为了保证HashMap的效率,系统必须要在某个临界点进行扩容处理,该临界点就是HashMap中元素的数量在数值上等于threshold(table数组长度*加载因子),loadFactor的默认值是0.75,这是一个折中的取值。但是,不得不说,扩容是一个非常耗时的过程,因为它需要重新计算这些元素在新table数组中的位置并进行复制处理。所以,如果我们能够提前预知HashMap 中元素的个数,那么在构造HashMap时预设元素的个数能够有效的提高HashMap的性能。

 负载因子loadFactor衡量的是一个散列表的空间使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于HashMap来说,查找一个元素的平均时间是O(1+a),a是元素在链表中的位置,因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。

 /**
     * Rehashes the contents of this map into a new array with a
     * larger capacity.  This method is called automatically when the
     * number of keys in this map reaches its threshold.
     *
     * If current capacity is MAXIMUM_CAPACITY, this method does not
     * resize the map, but sets threshold to Integer.MAX_VALUE.
     * This has the effect of preventing future calls.
     *
     * @param newCapacity the new capacity, MUST be a power of two;
     *        must be greater than current capacity unless current
     *        capacity is MAXIMUM_CAPACITY (in which case value
     *        is irrelevant).
     */
    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;

        // 若 oldCapacity 已达到最大值,直接将 threshold 设为 Integer.MAX_VALUE
        if (oldCapacity == MAXIMUM_CAPACITY) {  
            threshold = Integer.MAX_VALUE;
            return;             // 直接返回
        }

        // 创建一个更大的数组
        Entry[] newTable = new Entry[newCapacity];

        //将每条Entry重新哈希到新的数组中
        transfer(newTable);

        table = newTable;
        threshold = (int)(newCapacity * loadFactor);  // 重新设定 threshold
    }


6.HashMap的重哈希:transfer()

重哈希的过程主要是重新计算原HashMap中的元素在Capacity改变后在newTable[]中的位置,并进行复制处理。

 /**
     * Transfers all entries from current table to newTable.
     */
    void transfer(Entry[] newTable) {

        // 将原数组 table 赋给数组 src
        Entry[] src = table;
        int newCapacity = newTable.length;

        // 将数组 src 中的每条链重新添加到 newTable 中
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;   // src 回收
                
                do {
                    Entry<K,V> next = e.next;

                    // e.hash指的是 hash(key.hashCode())的返回值;
                    // 计算Entry在newTable中的位置,注意因为Capacity的增大原来在同一条子链上的元素可能被分配到不同的子链
                    int i = indexFor(e.hash, newCapacity);   
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

注意:由于Capacity的增大,原本属于一个桶的Entry对象可能会被分到不同的桶中。


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值