Java拾遗:002 - HashMap源码解读

哈希表

散列表(Hash Table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。这种数据结构在不考虑哈希碰撞的条件下,有前着O(1)的时间复杂度,因此效率很是高。Java中HashMap底层就使用了哈希表,因此一般咱们认为HashMap的时间复杂度也是O(1)。html

HashMap实现原理浅析

在JDK中,HashMap底层是由数组实现,该数组即为哈希表(由HashCode决定索引位置)。在不存在哈希碰撞的条件下,哈希表的性能最优,但在实际代码实现中不能不考虑这个问题。因此在HashMap中,每一个Bucket(哈希表中的节点)都是一个链表(JDK1.8中当链表元素超过8个时,会将链表转换为红黑树),当发生哈希碰撞时,该元素将被添加到链表的末端。因为链表中的时间复杂度是O(n),因此当Bucket所在链表过长时,会影响HashMap性能。java

JDK1.7 HashMap源码解读

JDK自1.6之后(之前的代码没读过)的版本HashMap的实现本质上没有太大差异(核心结构都是哈希表),这里以JDK1.7版本为例讲解,下面是HashMap的核心方法源码解读。数组

构造方法

下面是HashMap的主要构造方法,其它三个构造方法都是该方法的变种(经过默认参数实现)。数据结构

public HashMap(int initialCapacity, float loadFactor) {
        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();
    }

方法只是初始化了一些属性,this.loadFactor是扩容因子,即当实际使用容量比总容量为该因子时,将发生扩容。threshold表示扩容阈值,由总容量乘以扩容因子计算得出,但在构造方法中,直接使用初始容量表示。在无参的构造方法中,初始容量为16,扩容因子为0.75。多线程

isEmpty方法

判断集合是否为空的方法,实际只是内部维护了一个计数器,若是计数器为0即为空,不然非空。并发

public boolean isEmpty() {
        return size == 0;
    }
size方法

同理集合实际大小也是由该计数器表示,该计数器将在添加、移除元素时被维护。app

public int size() {
        return size;
    }
put方法

put方法是HashMap最核心方法之一,其代码实现复杂之处也在于此。函数

public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        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;
    }

代码中先判断哈希表是否为空,空则执行扩容(初始化哈希表的过程实际使用的扩容的逻辑,因此构造方法中用扩容阈值来表示初始容量,减小一个全局变量)。 初始化哈希表后,会判断键是否为空,空键不会使用哈希函数来计算哈希和索引,而是直接遍历哈希表找到空键所在Bucket,将元素加入该Bucket(该Bucket也是一个链表,但最多只能有一个元素,这就是HashMap中最多只能一个空键的缘由)。 若是键不为空,则计算它的哈希码,并根据哈希码找到其对应哈希表的索引。找到的Bucket是一个链表,遍历该链表,若是键已存在,则更新找到的节点值,将旧值返回,方法结束,若是没有找到该键,则做为一个新的节点加入链表末端。 上面解读中遗留了几个细节没讲,下面一一解读:性能

  • 扩容inflateTable(threshold);逻辑
private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);

        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

实际逻辑很简单,经过roundUpToPowerOf2方法保证扩容容量值必定是恰好大于等于传入容量值的2的整数次幂(关于这点的缘由,后面会解释),而后根据扩容后的容量计算扩容后的扩容阈值threshold,最后从新构造哈希表table(最后一行根据容量生成哈希种子的逻辑不影响主逻辑,这里略过)。实际上这个方法只在哈希表空时执行,只是一个初始化的方法,后续在添加新元素的过程当中触发扩容是由其它方法实现。优化

  • 空键的插入逻辑
private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

由源码能够看出只是简单的遍历哈希表,找到空键对应的Bucket(没有就新增一个),更新找到节点值,返回旧值。

  • 计算哈希码和根据哈希码查找索引
final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    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);
    }

生成哈希码的过程略过(我会告诉你是由于实现得太过复杂,我也不是很懂?),重点讲一下根据哈希码计算索引的逻辑。代码只有一行h & (length-1),这行代码实际上保证了返回值在[0 ~ 哈希表长度 - 1]这个区间内,若是不用位运算,它的等效(注意:这里是说等效,非等价,二者的返回值未必是相同的,但效果是一致的)实现为:h % length,但位运算的性能更好,因此使用了这种写法,另外使用位运算还有一个缘由,后面解释为何HashMap的容量必定是2的整数次幂里会讲到,这里先略过。 计算出索引后,直接从哈希表中获得Bucket(由于是经过下标查找,因此时间复杂度是O(1)),Bucket是一个链表,遍历这个链表找到对应的节点(键相同),更新节点值,返回旧值,若是未找到说明是一个新的节点,经过addEntry(hash, key, value, i);添加一个节点到链表末端。

  • 添加一个新节点
void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

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

    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

实际上添加一个新节点很容易,只须要构造一个Entry节点,添加到链表末端便可,但这里须要考虑的时,若是实际容量到达扩容阈值时,须要触发扩容逻辑。 首先经过resize(2 * table.length);将哈希表扩容为原来的两倍,而后从新计算哈希码和索引,最后经过createEntry(hash, key, value, bucketIndex);建立一个新节点,将其加入链表末端。 这里重点须要介绍一下resize方法(区别于inflateTable方法,这个方法会被屡次调用)

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);
    }

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        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;
            }
        }
    }

使用扩容后的容量构造一个新的哈希表,将原哈希表中的数据复制到新表中,再将新表赋值给类的table属性,从而完成扩容。

get方法

查找一个元素,在put方法中已经体现了查找过程,先计算哈希码,再计算索引,找到Bucket后遍历链表,根据键找到目标元素便可

public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }

    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : hash(key);
        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 != null && key.equals(k))))
                return e;
        }
        return null;
    }
remove方法

删除元素,先找到元素,将其从对应链表中删除便可,此时size计数器会递减

public V remove(Object key) {
        Entry<K,V> e = removeEntryForKey(key);
        return (e == null ? null : e.value);
    }

    final Entry<K,V> removeEntryForKey(Object key) {
        if (size == 0) {
            return null;
        }
        int hash = (key == null) ? 0 : hash(key);
        int i = indexFor(hash, table.length);
        Entry<K,V> prev = table[i];
        Entry<K,V> e = prev;

        while (e != null) {
            Entry<K,V> next = e.next;
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k)))) {
                modCount++;
                size--;
                if (prev == e)
                    table[i] = next;
                else
                    prev.next = next;
                e.recordRemoval(this);
                return e;
            }
            prev = e;
            e = next;
        }

        return e;
    }
clear方法

清空集合,只须要将哈希表(数组)全部元素置空,并将计数器归零便可

public void clear() {
        modCount++;
        Arrays.fill(table, null);
        size = 0;
    }

类重写hashCode方法时返回一个常数会引发什么问题?

前面讲过,哈希表的时间复杂度为O(1),但前提是没有发生哈希碰撞的状况下。若是一个类在重写hashCode方法时直接返回了一个常数(下次不能偷懒了~),就会形成存入集合的全部元素的哈希码相同,也就是哈希碰撞,那么HashMap的存储结构就会退化为一个链表结构(只有一个Bucket),而链表的时间复杂度为O(n),所以一个好的hashCode实现,能够提高其在HashMap或者HashSet等数据结构中的性能的,而只返回一个常数是万不可取的。

为何HashMap实际容量必定是2的整数次幂

以前提到过个问题,这时就要解释一下indexFor这个方法了。h & (length-1)使用了按位与运算,而该运算的特色是,参与计算的两个相同位都为1时输出1,不然输出0。参考一下下面的示例(假设当前容量为8):

# 8 - 1 == 7 转换二进制为 00000111
7 & 1 == 00000111 & 00000001 == 00000001 == 1
7 & 2 == 00000111 & 00000010 == 00000010 == 2
7 & 11 == 00000111 & 00001011 == 00000011 == 3

首先第一点,解释一下该方法是怎样保证返回索引必定在[0, length - 1]这个区间内。根据按位与运算的特色,(length - 1)中为1的项,在与任何数作按位与计算时才有可能为1,而(length - 1)中为0的项与任何数作按位与计算必定返回0,因此整个运算返回的最大值只能是(length - 1),而最小值是0,因此保证了[0, length - 1]这个区间范围。 HashMap实际容量必定是2的整数次幂也和这里的按位与运算有关。若是length是2的整数次幂(那么length的二进制必定是...1000...0的形式),那么(length - 1)的二进制必定是...0001111...1形式(这点读者自行去验证一下)。而在作按位与计算时,1才是会引发值变化的项,假设length为8,那么length-1的二进制就是00000111,任意数与其作按位与计算能够获得[0000, 0111]即[0, 7]全部项,而若是length不是2的整数次冥,那么length-1必须中间会有0(空项)存在,这意味着这些位置没法表示,从而形成哈希表中存在空洞(空间浪费),实际可用空间减小,那么哈希碰撞的概率就更大,即浪费空间,也影响效率。

# 下面是一组length不为2的整数次幂时,length - 1的二进制值
length = 10, length - 1 == 9 ==   00001001
length = 13, length - 1 == 12 == 00001100
length = 15, length - 1 == 14 == 00001110
... ...

因此只有length值为2的整数次幂时,length - 1才会是...1111...1的结构,作按位与计算才能表示全部值。

JDK1.8 HashMap源码有什么变化?

JDK1.8中对HashMap作了大量优化,代码细节调整很是多,但代码结构基本一致,也仍然使用哈希表,因此这里再也不展开, 有兴趣的自行阅读。JDK1.8中对HashMap作的最大调整是哈希表中单项(Bucket)再也不彻底使用链表结构了,当链表长度超过8时,将被转换为红黑树,而红黑树相比与链表有着更好的查询性能。

为何HashMap不适用于多线程场景

具体分析过程略,这里只说结论: 由于在HashMap中添加元素时,可能发生扩容,扩容过程当中会将遍历原来的链表数据,复制到新哈希表过,在多线程的状况下能够多个线程同时触发扩容,那么在这个过程当中颇有可能形成链表变成循环链表或者空表,致使数据get的时候死循环或者数据丢失的状况。 因此多线程(并发)场景下推荐使用ConcurrentHashMap,后面的文章中会介绍该集合类。

参考资料:

结语

HashMap在实际开发中用得很是多,其代码实现所涉及到的知识点也比较多,并且应用普遍,因此它的源码仍是颇有必要读一读的。本文主要介绍的是JDK1.7的源码,JDK1.8作了至关多的调整,后续有时间会深刻阅读一下JDK1.8的源码,尤为是关于红黑树这一块。

编写本文除了阅读源码之外,了参考了其它博主的文章(其实都比我写得好,不会画图是硬伤):

相关文章
相关标签/搜索