Java集合之HashMap

前面讲过ArrayList和LinkedList,它们都是List类型,对于List集合来说,它存储的元素除了有先后顺序关系外,不会在这个集合中表示出其他的联系。本文要讲的HashMap是Map类型,它同时存储key和value两个元素,并且key和value之间是一一对应的。换句话说,Map不光存储数据,并且存储数据的映射关系。

对于HashMap来说,其基本特性如下:

基本特性 结论
元素是否允许为null key和value可以为null
元素是否允许重复 key重复会覆盖,value可以重复
是否有序
是否线程安全

源码分析

本文使用的是JDK 1.8.0_201的源码。

成员变量

HashMap在JDK 1.7时基于数组+链表实现,在JDK 1.8时作了优化,变成了数组+链表+红黑树实现。我们来看下JDK 1.8下HashMap的成员变量:

成员变量 作用
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 底层数组的默认大小为16,这个数字必须为2的次方
static final int MAXIMUM_CAPACITY = 1 << 30 底层数组最大长度
static final float DEFAULT_LOAD_FACTOR = 0.75f; 默认的负载因子
static final int TREEIFY_THRESHOLD = 8; 桶的实现由链表转为红黑树的阈值
static final int UNTREEIFY_THRESHOLD = 6; 桶的实现由红黑树转为链表的阈值
static final int MIN_TREEIFY_CAPACITY = 64; 由链表转为红黑树,底层数组的最小长度
transient Node<K,V>[] table; 底层数组
transient Set<Map.Entry<K,V>> entrySet; entrySet缓存
transient int size; 实际存储的元素个数
int threshold; 需要进行resiz时size的大小,即capacity * load factor
final float loadFactor; 负载因子,默认为0.75

HashMap的源码明显比ArrayList和LinkedList要复杂。

put()操作

put操作的大致思路为:

  1. 对key的hashCode()做hash处理,然后再通过求模计算bucket的下标;
  2. 如果没有产生hash碰撞,直接放入bucket中;
  3. 如果产生碰撞,以链表的形式追加到桶后面;
  4. 如果链表的长度过长(即大于等于TREEIFY_THRESHOLD),就把链表转为红黑树;
  5. 如果节点已经存在就替换old value(保证key的唯一性);
  6. 如果该map中的元素个数超过阈值threshold(即capacity * load factor),就resize
public V put(K key, V value) {
    // 对key的hashCode()做hash处理
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // table为空则创建
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 计算桶的下标(等同于%求模运算),并判断该桶是否为null,即判断是否产生hash碰撞,如果该桶为null则创建一个桶
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 如果该桶不为null,即hash碰撞,则需要根据情况进行处理
    else {
        Node<K,V> e; K k;
        // 第一种情况,该桶的第一个节点与添加的元素相同
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 第二种情况,桶的实现为树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 第三种情况,需要往桶中添加一个链表节点
        else {
            // 遍历桶中的元素,为添加的元素寻找位置,注意这里的时间复杂度为O(n)
            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
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 写入操作
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 超过负载阈值,resize
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

get()操作

有了前面put操作的基础,再看get操作就容易多了。大致思路如下:

  1. 先检查map以及根据hash求模的桶是否有数据,如果没有数据,直接返回null;
  2. 判断是否命中该桶第一个元素,是则直接返回第一个元素value;
  3. 判断该桶的元素是否为红黑树,如果是则调用红黑树的方法获取value;
  4. 如果是普通节点,遍历节点元素,匹配就返回value,否则返回null。
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 先检查该map是否有数据,以及根据hash求模的桶是否有数据,如果没有数据则直接返回null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 若命中桶的第一个元素,直接返回第一个元素value
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // 如果是红黑树,调用红黑树节点的
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 如果是普通节点,遍历该桶的节点
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

hash()方法的实现

在put()和get()操作时,都对key做了hash操作。先看下源码:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这个方法的作用是:对key的hashCode值做计算,让其高16位不变,低16位与高16位进行异或计算。代码非常简单,但是其背后的设计思想并不是那么简单。

HashMap其底层数组table[]的长度一定是2的幂次方,在根据hash值计算table下标时,可以用 (n - 1) & hash 的方式代替求模 % 运算。这么做提高了计算table下标的效率,却容易导致hash碰撞。比如,n - 1为15(0x1111)时,hash真正有效的只是低4位,当然容易发生碰撞。

为了解决上面的碰撞,设计者将hashCode的低16位异或高16位。那么为什么不多异或几次呢?这里考虑到了综合性能,因为现在很多类的hashCode实现得很好,原本就很分散,多几次异或操作作用不大。另一方面,对于冲突较多的情形,使用树来解决频繁的碰撞。仅异或一次,既减少了系统的开销,也不会造成的因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞。

在JDK 1.8 之前,HashMap是用链表实现的,在产生碰撞的情况下,get操作的时间复杂度为O(n),因此当碰撞很厉害,n非常大的时候,O(n)的效率是不够好的。

因此JDK 1.8 采用红黑树代替链表,这样get操作的时间复杂度变为了O(log n) ,在n非常大的时候,能够明显的提高效率。在 《Java 8:HashMap的性能提升》 一文中有相关的性能测试结果。

resize()操作

当添加元素时,比如put操作,如果map的元素个数大于阈值(即size > threshold),就会触发resize。resize操作会将map的容量扩大为原先的2倍,而每个元素的桶下标要是不变,要么移动2的幂次方。

原理在于,计算桶下标的方法是:

(n - 1) & hash

现在n为原先的2倍,比如原先n为16,那么(n - 1)即15(0x1111),现在n为32,(n - 1)即为31(0x11111)。原先有效的hash位是4位,现在变成了5位。所以,hash位第5位是0的元素,仍然在原先的桶中,而hash位第5位是1的元素,将移动2的幂次方。

总结

为了加深对HashMap的理解,总结了以下几个问题:

1. 什么时候会使用HashMap?他有什么特点?

HashMap是基于Map接口的实现,存储键值对时,它可以接收null的键值,是非同步的,HashMap存储着Entry(hash, key, value, next)对象。

2. 你知道HashMap的工作原理吗?

HashMap通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前size的大小自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。

3. 你知道get和put的原理吗?equals()和hashCode()的都有什么作用?

通过对key的hashCode()进行hashing,并通过( n-1 & hash)求模的方式计算下标,从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点。

4. 你知道hash的实现吗?为什么要这样实现?

在Java 1.8的实现中,是通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。

5. 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

如果超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap。元素要么仍然在之前的bucket中,要么移动2的幂次方。

参考资料

《Java HashMap工作原理及实现》
《Java 8:HashMap的性能提升》

posted @ 2019-04-15 21:44  bluemilk  阅读(458)  评论(0编辑  收藏  举报