算法与数据结构二、HashMap深度剖析

概述

我们知道,数据结构的物理存储结构只有两种:顺序存储结构链式存储结构,像栈、队列、树、图等一系列结构都是基于这两种物理结构抽象出的逻辑结构。我们在第一篇文章中提到,数组根据下标查找定位某个元素,时间复杂度为O(1),哈希表利用了这种特性,哈希表的主干就是数组。

当我们要新增或查找元素,可以通过哈希函数计算该元素的哈希值从而映射到哈希数组中的某个位置。

哈希冲突
如果有两个不同的元素,哈希函数计算得到的值相同,映射到哈希数组相同的位置,当要进行put插入的时候发现位置已经被其他元素占用了,这就是哈希冲突,也叫哈希碰撞,又称散列冲突hash可以翻译为散列

所以哈希函数的设计非常重要,Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,Map的存取效率就会越高。

如果哈希数组很大,即使较差的Hash算法也会比较分散,如果哈希数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。

哈希碰撞无法完全避免,解决哈希碰撞的办法有:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址)、再散列函数法、链地址法、而HashMap就是采用了链地址法,也就是数组+链表的方式,将碰撞的元素追加存放在数组节点位置的链表上。

原理

HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。Map就是用来存放键值对的集合。

// HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂。
// 至于为什么这么做,后面会有详细分析。
transient Entry[] table = (Entry[]) EMPTY_TABLE;

Entry是HashMap中的一个静态内部类,代码如下:

static class Entry implements Map.Entry {
        final K key;
        V value;
        //存储指向下一个Entry的引用,单链表结构
        Entry next;
        // 对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
        int hash;

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        } 

那么,HashMap的总体结构如下:


HashMap由数组+链表组成,也就是哈希数组,称哈希表,又称链表散列(数据结构),意思是数组不是紧密排列的,散列方式可以有效地将冲突元素打散,不至于拥挤。数组是HashMap的主体,链表用于解决哈希冲突。在没有哈希冲突的情况下HashMap的查找和添加操作只需要一次寻址就可以定位。如果有哈希冲突,冲突的位置会形成链表。添加元素时会遍历链表,用equals对比如果存在相同KEY元素就覆盖,不存在就在链表末尾追加Entry,时间复杂度为O(n);查找操作也会遍历链表并通过equals对比KEY,匹配到相同的KEY就返回对应Value值,否则返回Null。

HashMap的四个重要属性:

/**实际存储的key-value键值对的个数*/
transient int size;

/**阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,
threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到*/
int threshold;

/**负载因子,代表了table的填充度有多少,默认是0.75
负载因子的存在是为了减缓哈希冲突,如果初始哈希数组大小为16,等到满16个元素才扩容,某些数组位置里可能就有不止一个元素了。
所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
*/
final float loadFactor;

/**HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时,
如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),
需要抛出异常ConcurrentModificationException*/
transient int modCount;

HashMap有四个构造器,如下:

public HashMap(int initialCapacity, float loadFactor) {
    // 此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(230)
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);

    this.loadFactor = loadFactor;
    threshold = initialCapacity;
    
    // init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现     
    init();
}
public HashMap(int initialCapacity) { // ...}
public HashMap() { // ...}
public HashMap(Map m) { // ...}

除了最后一个构造器,其余构造器都不会对table数组数组进行初始化。
下面看下put()函数的实现

public V put(K key, V value) {
    // 如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,
    // 此时threshold为initialCapacity 默认是1<<4(=16)
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    // 如果key为null,存储位置为table[0]或table[0]的冲突链上,null键只会有一个,新的null键的值会覆盖并返回旧值
    if (key == null)
        return putForNullKey(value);
    // 对key做hashcode计算,确保散列均匀
    int hash = hash(key);
    // 获取在table中的实际位置
    int i = indexFor(hash, table.length);
    // 取i位置已存在的Entry并迭代Entry链直到遍历结束或找到相同key
    for (Entry e = table[i]; e != null; e = e.next) {
        // 如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
        // hash相同并且(值或引用相同)则覆盖
        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;
        }
    }
    // 保证并发访问时,若HashMap内部结构发生变化,快速响应异常
    modCount++;
    // 新增一个entry
    addEntry(hash, key, value, i);
    return null;
}

inflateTable方法用来为哈希数组table在内存中初始化分配存储空间,并保证capacity一定是2的次幂。

private void inflateTable(int toSize) {
    // capacity一定是2的次幂
    int capacity = roundUpToPowerOf2(toSize);
    // 此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];
    initHashSeedAsNeeded(capacity);
}

roundUpToPowerOf2(toSize)函数用于将传入的toSize长度,经过计算返回一个合适的数组长度capacity,并确保计算后的capacity为在1到MAXIMUM_CAPACITY(1 << 30(即2的30次幂))之间的2的n次幂的数值,并且这个数值尽可能接近toSize(比如toSize=13则计算后的capacity=16,to_size=16则capacity=16,to_size=17则capacity=32)。
如果传入长度超过了MAXIMUM_CAPACITY就设置成设置成MAXIMUM_CAPACITY。
如果传入长度小于1就设置成1。

private static int roundUpToPowerOf2(int number) {
    // Integer.highestOneBit用来获取最左边的bit(其他bit位为0)所代表的数值
    // assert number >= 0 : "number must be non-negative";
    return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}

hash函数 非常棒的详细解释

// 哈希函数用了很多的异或,移位等运算
// 对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀
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);
}

如上扰动计算部分使用了四次>>>运算,因为>>>就是把低位去掉保留高位。然后高位和低位进行^位运算。这样不管是高位发生变化,还是低位发生变化都会造成其结果的中低位发生变化。

为什么我们关注其结果的中低位呢,那是因为后面算index的时候,用了h & (length-1),它的意思就是把高位去掉。
如果没有进行扰动计算,当key仅仅发生高位变动时&的结果不变,容易产生大量hash冲突

/**
 * 返回数组下标
 */
static int indexFor(int h, int length) {
    return h & (length-1);
}

h&(length-1)保证获取的index一定在数组范围内,位与操作的结果一定小于等于两个操作数。
所以put()操作确定到哈希数组位置的流程如下:


图片来自网络

再来看看addEntry的实现:

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        // 当size超过负载因子threshold,并且发生哈希冲突时进行扩容
        resize(2 * table.length);
        // 重新计算hash
        hash = (null != key) ? hash(key) : 0;
        // 重新获取下标
        bucketIndex = indexFor(hash, table.length);
    }
    // 创建新的Entry
    createEntry(hash, key, value, bucketIndex);
}

如上,当发生哈希冲突并且size大于负载因子的时候,会进行数组扩容,会新建一个长度为之前数组2倍的新数组,然后将当前的哈希数组中的元素全部传输过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。

继续看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, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    // 重新设置负载因子
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

接下来先来看看transfer方法

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    // 遍历哈希数组
    for (Entry e : table) {
        // 遍历数组当前数组位置的链表,将链表每个节点重新指向到新数组
        while(null != e) {
            // 保存下一次循环的 Entry
            Entry next = e.next;
            // 如果需要,重新计算哈希值
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 重新计算Entry指向的下标
            int i = indexFor(e.hash, newCapacity);
            // 将e直接插入到newTable[i]内容的头部(头插法)
            // 头插法:不论newTable[i]内容为空还是不为空的entry链,直接将其追加在e后面
            e.next = newTable[i];
            // 将e放到newTable[i]位置作为头部
            newTable[i] = e;
            // 指向下一个要移动的结点
            e = next;
        }
    }
}

数组长度保证为2的次幂的原因:
是为了让Key计算到的索引值均匀的分配到哈希数组中:
举几个例子,比如length=16的二进制是10000,length-1=15二进制为01111;length=32的二进制是100000,length-1=31二进制为011111。依次类推2的次幂的数字length-1二进制低位相同都是1。假如length=64,进行获取数组下标计算h & (length-1)如下图:

图片来自网络

length-1高位都为0与h相与也是0,h的低位任何一个位的变化都会让计算出的下标结果产生变化,从而使得哈希数组中索引值均匀分布。在看下图,length不是2的次幂的情况如下图length=62:


图片来自网络

length-1的低位第二位为0,与h进行与操作,无论h的低位第二位是0还是1计算到的数组下标都相同,这也导致哈希数组中key的索引值非均匀分布。

我们再举两个例子来实际佐证:
例一: 假定 length = 50(非 2 的整数次幂),二进制值为0011 0010,这里我们使用8位二进制数来进行计算。length - 1 = 49,二进制值为0011 0001。我们计算任何整数与49进行与运算的可能的结果如下:

0000 0000 //0
0000 0001 //1
0001 0000 //16
0001 0001 //17
0010 0000 //32
0010 0001 //33
0011 0000 //48
0011 0001 //49

可能的结果值为:0、1、16、17、32、33、48、49,对于一个长度为 50 的数组,我们只命中了其中的8个索引值,产生了索引值的非均匀分布。

例二:假定length = 32(2的五次幂),二进制值为0010 0000,这里我们使用8位二进制数来进行计算。length - 1 = 31,二进制值为0001 1111。我们计算任何整数与31进行与运算的可能的结果如下:

0000 0000 //0
0000 0001 //1
0000 0010 //2
0000 0011 //3
0000 0100 //4
0000 0101 //5
0000 0110 //6
0000 0111 //7
0000 1000 //8
0000 1001 //9
0000 1010 //10
0000 1011 //11
0000 1100 //12
0000 1101 //13
0000 1110 //14
0000 1111 //15
0001 0000 //16
0001 0001 //17
0001 0010 //18
0001 0011 //19
0001 0100 //20
0001 0101 //21
0001 0110 //22
0001 0111 //23
0001 1000 //24
0001 1001 //25
0001 1010 //26
0001 1011 //27
0001 1100 //28
0001 1101 //29
0001 1110 //30
0001 1111 //31

可能的结果值为:0 到 31,对于一个长度为 32 的数组可能的索引值范围也刚好是 0 到 31,以此类推,当HashMap中数组的size为2的整数次幂时,可以保证 key的hash值被均匀的分布到数组上。因此建议将HashMap的initialCapacity值设置为2的整数次幂。其实,在HashMap的put方法中在初始化数组的inflateTable方法代码中已经做了优化处理,前面有过介绍。

get方法:

public V get(Object key) {
    // 如果key为null直接去table[0]处去检索
    if (key == null)
        return getForNullKey();
        Entry entry = getEntry(key);
        return null == entry ? null : entry.getValue();
}

继续getEntry方法:

final Entry getEntry(Object key) {            
    if (size == 0) {
        return null;
    }
    // 通过key的hashcode值计算hash值
    int hash = (key == null) ? 0 : hash(key);
    // indexFor (hash&length-1)获取数组下标,然后遍历下标位置链表,比较哈希值&&(key值相等或者引用相等)
    for (Entry 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;
    }
}    

通过对put的学习,理解get明显轻松许多,key(hashcode)–>hash–>indexFor–>最找到对应位置table[i],再查看是否有链表,遍历链表,通过比较哈希值&&(key值相等或者引用相等)找出对应记录。

有人认为上面e.hash == hash这个判断没必要,仅通过equals判断就可以。其实不然,试想一下,如果传入的key对象重写了equals方法却没有重写hashCode,而恰巧此对象定位到这个数组位置,如果仅仅用equals判断可能是相等的,但其hashCode和当前对象不一致,这种情况,根据Object的hashCode的约定,不能返回当前对象,而应该返回null,后面的例子会做出进一步解释。

重写equals方法需同时重写hashCode方法
下面通过栗子看一看如果重写了equals而不重写hashcode会导致什么问题:

public class HashCodeTest {
    private static class Superhero {
        int idCard;
        String name;

        public Superhero(int idCard, String name) {
            this.idCard = idCard;
            this.name = name;
        }
        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()){
                return false;
            }
            Superhero superhero = (Superhero) o;
            // 两个对象是否等值,通过idCard来确定
            return this.idCard == superhero.idCard;
        }

    }
    
    public static void main(String []args){
        HashMap map = new HashMap();
        Superhero superhero = new Superhero(1234, "Wonder Woman");
        // put到hashmap中去
        map.put(superhero, "超级英雄");

        Superhero superhero1 = new Superhero(1234,"Wonder Woman");
        System.out.println(superhero.hashCode());
        System.out.println(superhero1.hashCode());

        // get本意是取出"超级英雄"
        System.out.println("结果:" + map.get(superhero1));
    }
}

结果:null

如上,本意是取出“超级英雄”而实际结果是null。通过前面对hashmap的学习,不难理解,因为superhero与superhero1的hashcode不同,导致返回结果为null。默认hashcode是JVM底层通过一套复杂计算得到的。所以我们重写equals要同时重写hashcode方法,以避免发生上述情况。

JDK8对HashMap的优化
JDK7HashMap基于数组+链表实现,以一个Entry数组为基础:
Entry[] table;
JDK8中HashMap基于位桶+链表/红黑树实现,以一个Node数组为基础:
Node[] table;

JDK7中Hashmap碰撞节点存储在链表中,查找时的时间复杂度为O(n),相对来说比较低效。
JDK8中通过红黑树解决了这一问题,当链表的存储的数据个数大于等于8时,转换为红黑树结构存储。


图片来自网络

红黑树查找的时间复杂度为O(logn),可以大大提高性能。

不同点
JDK7发生hash冲突时,新元素插入到链表头中,即新元素总是落到数组下标位置,下标位置已有Entry追加在链表中。 JDK8发生hash冲突后,会优先判断该节点的数据结构是红黑树还是链表,如果是红黑树,则在红黑树中插入数据;如果是链表,则将数据插入到链表的尾部并判断链表长度是否>=8,如果大于8要转成红黑树,当红黑树元素个数<=6会重新转为链表(防止频繁转换)。链表大于8才转换成红黑树的原因:

  1. TreeNode占用存储空间是Node的2倍,尽量用Node
  2. 参考

综合时间和空间因素考虑选用8为临界点

JDK7的扩容resize方法,采用单链表的头插入方式,在将旧数组上的数据转移到 新数组上时,转移操作按旧链表的正序遍历链表,在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况,多线程下resize容易出现死循环。此时如果并发执行put操作,一旦出现扩容情况,容易出现环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop),即死锁的状态 。JDK8转移数据操作按旧链表的正序遍历链表,在新链表的尾部依次插入,所以不会出现链表逆序(倒置)的情况,所以不会出现环形链表的情况。

循环链表
现象:HashMap.get()导致CPU100%,哈希碰撞概率越高越容易出现。
transfer方法中,假设有两个线程T1、T2同时对Hashmap操作,假设key(3、5、11都映射到图中的位置)

// 遍历数组当前数组位置的链表,将链表每个节点重新指向到新数组
        while(null != e) {
            // 保存下一次循环的 Entry
            // 假设线程T1执行到这里就被调度挂起了
            Entry next = e.next;  
            // 如果需要,重新计算哈希值
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 重新计算Entry指向的下标
            int i = indexFor(e.hash, newCapacity);
            // 将e直接插入到newTable[i]内容的头部(头插法)
            // 头插法:不论newTable[i]内容为空还是不为空的entry链,直接将其追加在e后面
            e.next = newTable[i];
            // 将e放到newTable[i]位置作为头部
            newTable[i] = e;
            // 指向下一个要移动的结点
            e = next;
        }

线程T1执行到Entry next = e.next; 被调度挂起,线程T2执行完成,得到下面的状态:


图片来自网络

上文因为T1的e指向了key(3),而next指向了key(7),而e和next在T2执行rehash后,指向了线程二重组后的链表。我们可以看到图中线程T2链表的顺序被反转了。

接下来线程T1被调度回来继续
先执行 newTalbe[i] = e; (头插)
然后是e = next,使了e指向key(7),
指向key(7)的e进入下一次循环,导致next指向key(3)(T2将key(3)指向到key(7).next)

图片来自网络

这是后还没出现问题,T1继续执行,把key(7)放到newTable[i]的头部,然后把e和next往下移。


图片来自网络

此时,环形链接出现。
e.next = newTable[i] 导致 key(3).next 指向了 key(7),而此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。


图片来自网络

于是,当线程调用到,HashMap.get(11)时,就出现了无限死循环(Infinite Loop)。

你可能感兴趣的:(算法与数据结构二、HashMap深度剖析)