Java7 中 HashMap 的实现原理与底层数据结构

Hash结构

HashMap根据名称可知,其实现方法与Hash表有密切关系。在讨论哈希表之前,我们先大概了解下其他数据结构在新增,查找等基础操作执行性能。

数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)

线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)

二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。

哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。

我们知道,数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。
  比如我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。
   location = hash(关键字)
其中,hash函数设计的优劣直接影响整体的性能。

哈希冲突
哈希算法存在一个缺点就是哈希冲突。例如,我们进行数据存储时,我们通过对关键字进行hash时得到的地址已经存储过数据了,这时就会出现哈希冲突。因此,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀。但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。

HashMap代码实现

HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。

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

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

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

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

HashMap的整体结构如下:

在这里插入图片描述

简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的。

如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;

如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。

所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

其他几个重要字段:

// 实际存储的key-value键值对的个数
transient int size;
// 阈值,简单理解就是当HashMap的size大于threshold时会执行resize操作
// 当table == {}时,该值为初始容量(初始容量默认为16)
// 当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactor。
// HashMap在进行扩容时需要参考threshold,后面会详细谈到
int threshold;
//负载因子,代表了table的填充度有多少,默认是0.75
final float loadFactor;
// 用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;

HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值,initialCapacity默认为16,loadFactory默认为0.75。下面给出一个构造函数示例:

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(); // init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
    }

从上面这段代码我们可以看出,在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组。

HashMap-put方法

public V put(K key, V value) {
        // 如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold
        // 此时threshold为initialCapacity 默认是1<<4(2的4次方=16)
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        // 如果key为null,存储位置为table[0]或table[0]的冲突链上
        if (key == null)
            return putForNullKey(value);
        // 对key的hashcode进一步计算,确保散列均匀
        int hash = hash(key);
        // 获取在table中的实际位置
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            // 如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
            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++;
        addEntry(hash, key, value, i);
        return null;
    }
private void inflateTable(int toSize) {
        // capacity一定是2的次幂
        int capacity = roundUpToPowerOf2(toSize);
        // 此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值
        // capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

inflateTable这个方法用于为主干数组table在内存中分配存储空间,通过roundUpToPowerOf2(toSize)可以确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32。

private static int roundUpToPowerOf2(int number) {
        // 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);
    }

以上hash函数计算出的值,通过indexFor进一步处理来获取实际的存储位置

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

h&(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为

   1 0 0 1 0
&  0 1 1 1 1
__________________
   0 0 0 1 0  = 2

最终计算出的index=2。有些版本的对于此处的计算会使用 取模运算,也能保证index一定在数组范围内,不过位运算对计算机来说,性能更高一些(HashMap中有大量位运算),所以最终存储位置的确定流程是这样的:

在这里插入图片描述

添加entry

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            // 当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

通过以上代码能够得知,当发生哈希冲突并且size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素全部传输过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。 

如何扩容?

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, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

如果数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index,我们先来看看transfer这个方法

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
     // for循环中的代码,逐个遍历链表,重新计算索引位置,将老数组数据复制到新数组中去(数组不存储实际数据,所以仅仅是拷贝引用而已)
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
         // 头插
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

这个方法将老数组中的数据逐个链表地遍历,扔到新的扩容后的数组中,我们的数组索引位置的计算是通过 对key值的hashcode进行hash扰乱运算后,再通过和 length-1进行位运算得到最终数组索引位置。

容量总是为2的次幂

前文提到,计算数组下标时的方法,h & (length-1),位运算方法速度快。

当 length 为2的次幂时,保证 length-1 的二进制除最高位为0,其余位都为1,减少哈希冲突;

同时满足 h & (length-1) = hash % length。

为什么是hash&(length -1)?

举个例子:

令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:

x :       00010000
x-1 :     00001111

令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:

y :       10110010
x-1 :     00001111
y&(x-1) : 00000010

这个性质和 y 对 x 取模效果是一样的:

y :       10110010
x :       00010000
y%x :     00000010

这里 只有x是2的n次幂,那么求y和x-1的&操作时,才能相当于y%x这种求模的操作。这就是为什么hashmap里数组长度必须是2的次幂 。

(有兴趣的可以看一下源码里 roundUpToPowerOf2 函数,在扩容时,用了非常巧妙的移位操作找到了比cap大的 最小的 是2的某次幂的 一个数返回设定为数组长度length )

为了分析这个取模操作和数组长度是2的次幂的关系,我们举个例子:

如果长度是17,length-1=16 y和16&操作后后4位一定是0,因为 16是 1 0000 后面4个0,加上&后四位必是0,那么只有从第5位才有分别,即最小的差距就是0和16的差距,严重违背了均匀分布的要求,会导致大量数组空间浪费和碰撞。

而如果长度是16 ,length-1=15 ,15是 1111 相当于取低4位的模,最小的差距是0和1的差距,就把hashcode的值均匀分布在0-15里面了,这样才符合求hash值,均匀分布以减少碰撞的要求。

我们知道,位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能。

确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%length,如果能保证 length 为 2 的 n 次方,那么就可以将这个操作转换为位运算。

所以这个操作即是让hash在数组长度以内的取模运算,高效同时减少哈希冲突。

那么,直接调用Object的hashCode()然后用上面的方法取模行么?

再举个例子:

假设此时table[]的length是16,那么对于key1来说就是

0AC20000 & 0000000F = 0

对于key2来说

017F0000 & 0000000F = 0

也就是说key1和key2的存储位都是0,发生了hash冲突。

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

扰动的方法前面源码里已经出现了!就是这一段!

    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);

我们按上面的代码一点点分析,加入扰动函数之后:

0AC20000 >>> 20 = 000000AC
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冲突。

逻辑总结

其实前面已经说过了

第一步,求hash值

第二步,根据hash值和数组的长度length定位到数组里的具体位置。

  1. 求hash值时,先调用key的Object.hashCode()得到一个hashcode,
  2. 然后对它进行高位绕动,防止高位不同低位相同后造成的冲突情况。
  3. 这样得到了hash值,再把此值用巧妙的位操作的方法取模(借助数组长度length是2的某次幂)至数组长度内均匀分布。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值