HashMap的来龙去脉

本文深入解析HashMap的实现原理,包括数据结构的选择、构造函数、put操作的流程,特别是为何数组长度选择2的次幂以及如何处理哈希碰撞。讨论了扩容策略以及Java 1.8后采用尾插法的原因,同时提到了HashMap在实际使用中的注意事项,并引入了Android中的SparsArray和ArrayMap作为对比。

我们学习数据结构的时候,最开始入门的也是最基础的两种数据结构是线性表和链表结构。

线性表以数组为代表,数组我们平时很常用,我们都知道数组有个优点就是查询和修改很快这个具体的原理是因为数组在申请内存时,申请的是一块连续的相等大小的内存,那么我们在查找某个下标的元素的时候,只要知道了首地址、下标、每个元素所占的内存,那么我们很快就可以计算出目标地址,一下子就找到,其时间复杂度为O(1),但是数组的增删效率比较低,那是因为我们在增加或者删除一个元素的时候,该元素往后的其余元素都要随之逐个移动,这个移动的时间复杂度是O(n);

那还有另一种数据结构是链表,链表由一个一个的节点连接而成,链表在内存中存储并不是连续的,所以每个节点除了保存数据外,还需要额外一个指针保存下一个节点的地址(我们这以单向链表为例吧),链表有个优点,就是增删效率很高,比如我们要删除某节点X,只要让X的前一个节点指针指向X的后一个节点,然后释放掉X的内存即可,时间复杂度为O(1),但是链表的查找和修改效率就比较低了,如果我们要找个某一个元素,则需要从第一个节点开始逐个遍历才行,所以其时间复杂度为O(n)。

这两种数据结构各有各的优点,在实际开发中,如果查询多就用数组,增删多就用链表。

那么有没有一种数据结构可以吧这两种数据结构的优点都结合起来呢~没错,写这篇文章就是为了介绍HashMap!

HashMap的原理细讲的话内容太长,我们抓几个关键的点来入手吧。

我们点进源码从它的几个常量开始看:

    // 默认初始化长度
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    // 最大的容量
    static final int MAXIMUM_CAPACITY = 1 << 30;

    // 默认加载因子,当map中已使用的数组长度占到总长度的75%时进行扩容,扩容比为两倍
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // 转换成树结构的阈值,至于为什么是8,要根据概率论中的泊松分布原理,超过8后的概率已经足够小了
    static final int TREEIFY_THRESHOLD = 8;

    // 取消树结构的阈值
    static final int UNTREEIFY_THRESHOLD = 6;

    // 转换成红黑树的最小数组长度
    static final int MIN_TREEIFY_CAPACITY = 64;

从常量上看,很容易误以为HashMap的默认长度是16,然而并不是,那我们就看看构造函数吧:

    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;
        this.threshold = tableSizeFor(initialCapacity);
    }


    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

当我们不传初始长度的时候就会使用默认的长度(但是其实这时候还没有进行HashMap结构的初始化),如果传了长度,就会走两个参数的构造方法了。在方法中,首先对传入的参数进行判断是否合法,我们看到里面用到一个方法Fload.isNaN(),这是因为在Float类中定义了一个值 public static final float NaN = 0.0f / 0.0f;这个是一个未知的值,所以不允许使用,校验完参数之后,构造函数里面有一个操作:

this.threshold = tableSizeFor(initialCapacity);

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

官方对这个方法的注释是:返回给定目标容量的二次幂。通过看方法中的运算过程,就能理解了,其实就是返回一个与之最相近的并且大于它的2次幂的值(位运算不熟悉的就需要去补一下咯)。所以尽管我们有传入初始值,但是都会被转换成与之最相近的且大于它的2的次幂值。那么为什么需要这个操作呢,这不由让我们回想起常量中的初始长度 1 << 4即2^4 = 16。HashMap在选数组长度的时候为什么要选2的次幂数呢?这里我们先埋个坑,等会儿过来填。看完构造函数,我们就要开始添加一个元素了,我们看看put的函数:

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

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

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null) //找到相应位置,如果当前为空,直接生成新的节点存入
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))//如果key已经存在,直接覆盖为新值
                e = p;
            else if (p instanceof TreeNode)//当前采用红黑树解决hash冲突,则直接往树中添加节点
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {//如果不满足前面的条件,下面就要进行各种判断
                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) { // 如果链表中找到相同的key,则直接覆盖为新的值
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();//如果达到了阈值,则进行扩容
        afterNodeInsertion(evict);
        return null;
    }

大致流程就是这样的:首先对key求得hash值并取高16位,这样我们把元素添加进去的时候更具有随机性,put的时候会先判断map的数组是否已经初始化过(所以HashMap的new的时候并未进行初始化内部结构,而是在put的时候才进行处理,这么做主要也是为了内存),未初始化的话,会经过一系列判断并给定其初始化长度,然后放入到数组中的相应位置,这个位置怎么计算的呢,tab[i = (n - 1) & hash],这里的n是数组的长度,可以看出,就是前面求得的高16位hash值对长度-1进行按位与运算(其实就是取模运算)。那么这里就开始解释前面所说的,数组长度必须是2的次幂。分析下,我想在数组中找到一个位置来存储元素,那么下标的范围必然是0~n-1,这就是为啥对n-1进行求模运算,然后为啥要是2的次幂呢?假设我们长度是10吧,那么10-1 = 9,算一下二级制为1001,然后我有一个key求得hash值为13,二进制是1101,按位与

求得结果是9,但是我有另一个key求得hash值是11,二进制是1011,按位与同样求得结果9,很容易看出问题因为1001中间两位是0,而与运算的时候,0和1 对0进行与运算,结果都是0,这样运算结果有很大的不确定性,很大概率上增加了重复的概率,反而数组中有些位置却一直没机会用上。所以我们使用四位都是1,即1111,这样才能保证运算结果的唯一性,1111即15,所以长度为16即2^4。这就是为什么HashMap在初始化数组长度的时候要使用2的次幂。

那么数据大时,难免会出现求得的hash值一致的情况,这现象称为哈希碰撞,为了解决这个问题,HashMap在数组的每个元素中使用链表,计算到下标一致但是key不一致的都插入到链表中(1.8之前采用头插法,1.8开始使用尾插法,目的是为了解决rehash发生的问题,下面补充说明)。

那么我们对put的过程进行一个概括就是:先通过hash值对数组长度-1进行取模找到数组中需要插入的位置,如果插入的位置是空的(未发生哈希碰撞),则直接插入新节点,如果发生了哈希碰撞,则判断当前数组该位置的情况,如果存在相同的key,则直接覆盖为新的值;如果不存在key,则需要插入,那么就判断当前是红黑树的话,就生成新的树节点插入树中,如果不是则生成新的节点,采用尾插法的方式插入链表中,然后对链表长度进行判断,如果达到了红黑树的阈值8,因为红黑树的查找效率更高,则将其树化转成红黑树。同时也在判断数组的长度,如果达到数组长度 * 扩容因子(0.75),则进行扩容,数组长度变为原来的两倍,大致是这样,但是树化中的操作又是怎样的呢?

    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize(); //如果未达到数组长度的阈值64,则先进行扩容
        else if ((e = tab[index = (n - 1) & hash]) != null) { //下面的操作就是链表的树化操作了
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

可以看出,扩容优先于树化,如果没达到数组大小的阈值,即使单链表的长度达到了8,也是先进行数组扩容,扩容的时候也会对所有的元素重新hash计算找到新的位置

final Node<K,V>[] resize() {
    //得到当前数组
    Node<K,V>[] oldTab = table;
    //第一次put,数组未初始化,长度为0,否则返回当前数组的长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //当前阈值
    int oldThr = threshold;
    int newCap, newThr = 0;
    //如果旧的数组长度大于0,开始计算扩容后的大小
    if (oldCap > 0) {
        // 超过最大值就不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            //修改阈值为int的最大值
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //如果扩容后长度未达到最大容量,并且原长度大于等于默认初始长度(已经put过元素)
            newThr = oldThr << 1; // double threshold 阈值扩容一倍
    }
    else if (oldThr > 0) // 旧阈值大于0,则赋值给新的数组长度
        newCap = oldThr;
    else {// 老阈值为0,则使用默认值进行初始化
        newCap = DEFAULT_INITIAL_CAPACITY;//16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 计算新的resize最大上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    //新的阈值为原来的2倍
    threshold = newThr;
    //创建新的哈希表
    @SuppressWarnings({"rawtypes","unchecked"})
    //newCap是新的数组长度,为原来的2倍
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //判断旧数组是否等于空
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {//遍历数组中每个桶的元素,重新计算新的位置
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                //原来的数据赋值为null 便于GC回收
                oldTab[j] = null;
                //判断数组是否有下一个引用
                if (e.next == null)
                    //没有下一个引用,说明不是链表,当前桶上只有一个键值对,直接插入
                    newTab[e.hash & (newCap - 1)] = e;
                //判断是否是红黑树
                else if (e instanceof TreeNode)
                    //说明是红黑树来处理冲突的,则调用相关方法把树分开
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // 采用链表处理冲突
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    //如何计算新位置,在下面进行分析
                    do {
                        // 原索引
                        next = e.next;
                     	//这里来判断如果等于true e这个节点在resize之后不需要移动位置
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

我们来分析下resize的方法同时也能清楚为啥要按照两倍的方式进行扩容。我们前面谈到,为什么数组长度要是2的次幂,也就很好理解为什么要按照2倍的方式进行扩容,如此一来扩容后的长度其实相对之前的长度,其二进制数就是多了一位。所以节点重新计算位置时要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。如:

下面的1是扩容后的,如果原来的hash值对应位是0,则该元素在原来的位置;但如果对应位是1呢,则计算结果为10000+1101,即在原来的容量基础上偏移了原来的位置。

 

所以每个新的桶(HashMap把数组的每个item里面存储的内容定为bucket桶)的长度肯定小于或者等于原来的长度。

这里补充说明下,为什么Java1.8之后改为尾插法,比如在数组5位置有两个节点 B->A,然后扩容的时候要进行rehash,B继续放到5的位置,然后A也放到5的位置,这时候就有A->B,那么就有一个问题,原先是B->A,现在是A->B,这样形成一个循环链表,那么在查找一个不存在的key的时候,如果进入了循环链表,是不是就进入了死循环了。。那么换成尾插法的话,原先是A->B后来rehash了,还是A->B,老铁,没毛病。

如此HashMap的put方法就差不多分析完了,接下来看下remove方法。

    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }

    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) { //找到要删除的节点在数组中的位置
            Node<K,V> node = null, e; K k; V v;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))//判断数组存的头节点是否就是要删除的节点,如果key相同则为要删除的节点
                node = p;
            else if ((e = p.next) != null) {//如果头节点不是要找的节点判断该桶是否为树,是树的话采用树的查找方式找到目标节点
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else { //不是树的话,则为链表,采用链表的方式遍历查找目标节点
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode) //找到节点后,按照红黑树的方式删除该节点
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p) //通过链表的方式删除节点
                    tab[index] = node.next;
                else
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

概括一下上述的过程就是首先在map中查找要删除的节点,查找方式根据里面的具体结构采用不同的方式。找到节点后也根据具体的数据结构采用不同的方式进行删除。那么get方法的原理和remove中查找的原理大体是相同的,就不再分析了。由此,HashMap的大致原理我们都过了一遍。然后有些使用过程需要注意的地方:

初始化HashMap的时候按照大概预计使用的情况给定初始长度,这样map就不用在put的过程中频繁地resize,因为resize是很耗性能的。

但是HashMap有个问题,就是扩容的时候,如果我们只剩一个节点无处放,则需要对数组进行扩容,这时候也是按照2倍进行扩容,这样就会导致内存占用大,使用率低。

所以Android官方推出了一个新的数据结构:SparsArray和ArrayMap,那这后面就讲讲这两个数据结构吧。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值