阅读 667

深入了解HashMap

深入了解HashMap

HashMap简介

HashMap是日常开发中常用的一种数据结构。在JDK 1.8中,HashMap采用数组+链表+红黑树的方式实现。本篇基于JDK 1.8源码进行解读。

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    //...
}
复制代码

​ 基础节点是单链表,存储在Node<K,V>[] table数组中。当满足一定的条件,则会转换成红黑树,以避免链表过长查询效率低下的问题。

构造方法

1、默认构造会把loadFactor方法设置为DEFAULT_LOAD_FACTOR即为0.75f,loadFactor会影响threshold,当表容量达到了最大容量的百分比之后,就会触发扩容。一般来说,0.75f是经过验证后的一个比较合适的数值。

2、指定容量的构造方法,会针对传入的initialCapacity进行计算

/**
 * Returns a power of two size for the given target capacity.
 */
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;
}
复制代码

tableSizeFor会把传入的数据的低位全变成1,再加上1之后就会变成2的幂,返回的容量赋值给threshold,为什么要让容量为2的幂这里是有讲究的。

put

put方法会先计算key的hash,再调用putVal方法来实际的存储数据。

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

​ hash会用该key的hash值与高16位进行异或得来,减少hash碰撞。

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)
      //第一次存储 初始化tab
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
      //tab标对应位置没有数据,创建新Node存入,使用方法创建,方便被重写
        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))))
            e = p;//第一个节点与插入元素的key相同
        else if (p instanceof TreeNode)
          //尝试插入红黑树节点中
            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) 
                      //链表长度过长,尝试转换红黑树
                        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;
          //给LinkedHashMap 保留的埋点
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
      //达到扩容的临界点了 去扩容
        resize();
    afterNodeInsertion(evict);
    return null;
}
复制代码

putVal方法首先去判断当前tab有无初始化,如果没有则调用resize方法初始化。

取下标的方法采用(n - 1) & hash来处理,如果tab数组对应下标位置没有数据,则创建一个新的节点存入。如果对应节点是一个红黑树节点,那么则尝试把此数据存入红黑树中。如果有数据,而且节点是普通的链表节点,则会对链表进行遍历,存储数据到链表的末尾。如果链表长度达到了TREEIFY_THRESHOLD(8),则会尝试转换为红黑树。来看看treeifyBin方法:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
  //如果tab长度小于MIN_TREEIFY_CAPACITY(64),则优先扩容,避免碰撞太多
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    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并且数组长度>=64的时候才会进行红黑树转换。putVal方法操作了数据之后,都会调用一些埋点方法,为LinkedHashMap的实现提供了基础。

resize

resize方法是存储数据时候执行的一个比较重要的方法,因为源码比较长,这里单独分析一下:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
  //之前的tab容量不为0 说明已经初始化过了
    if (oldCap > 0) {
      //数组容量达到了上限 不进行扩容了
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
          //可以进行扩容则扩容一倍
            newThr = oldThr << 1; 
    }
    else if (oldThr > 0) 
      //构造方法中设定了容量
        newCap = oldThr;
    else {             
      //未初始化数组 则设为默认容量16
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
      //扩容容量界标为数组容量*loadFactor
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
        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) {
                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;
                      //此节点的下标相较于原数组不改变
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                          //此节点的下标相较于原数组+oldCap
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
复制代码

​ 来详细解读一下,resize方法会先对旧的数组大小进行一次判断,如果已经初始化过后,则尝试扩容。如果数组容量已经达到上限,则不会继续扩容,否则会尝试扩容为之前大小的两倍。如果构造方法中自定了容量,则会把新的容量设定为此容量,然后重新计算扩容临界点。如果采用了默认的构造方法,会初始化数组容量为16。

​ 扩容之后,不是单纯的复制就数组的数据到新数组,而是会重新取新下标。如果之前的下标位置的节点没有子节点,则会在取新节点之后直接存入。如果有子节点,这时候的处理就比较有意思了。

为什么表的大小取2的幂?

​ 这是JDK 1.8里一个比较巧妙的设计,因为容量为2的幂,所以以二进制来看,tabCap除了高位,别的数值都为0,那么在-1后低位都会变为1,而取下标的方式为hash & (n -1)。以表初始容量16为例子,假设一个key的hash值为11001100,另外一个hash值为11011100,分别计算下标:

int index0 = 11001100 & 1111;//hash & (n -1) = 1100
int index1 = 11011100 & 1111;//hash & (n -1) = 1100
复制代码

​ 在旧的数组里,这两个key的hash值虽然不同,但是计算得的下标都是一样的,为12。如果数组扩容,则容量会变成32,这时候取下标就变成如下了:

int index0 = 11001100 & 11111;//hash & (n -1) = 1100
int index1 = 11011100 & 11111;//hash & (n -1) = 11100
复制代码

​ 可以看出index0下标不会改变,还是12,但是index1的下标会发生改变,变成28。

​ 这里就可以看出数组容量为2的幂大小设计的巧妙之处了。当数组扩容之后,我们只要关心hash在oldCap的那一位的数值是0还是1即可确认新下标了,如果是0那么新下标也不会发生改变,如果是1则新下标就是旧下标的基础上加上oldCap,这样设计即可解决原数组里的hash碰撞的问题,也避免了过多的运算,实在是很巧妙。

TreeNode节点的split

​ 红黑树节点在扩容会也会进行切割,针对不同的hash计算生成新树,取下标的逻辑也是和上面说到的方法一样。

if (loHead != null) {
    if (lc <= UNTREEIFY_THRESHOLD)
        tab[index] = loHead.untreeify(map);
    else {
        tab[index] = loHead;
        if (hiHead != null) // (else is already treeified)
            loHead.treeify(tab);
    }
}
if (hiHead != null) {
    if (hc <= UNTREEIFY_THRESHOLD)
        tab[index + bit] = hiHead.untreeify(map);
    else {
        tab[index + bit] = hiHead;
        if (loHead != null)
            hiHead.treeify(tab);
    }
}
复制代码

​ 切割完红黑树之后,会判断新树的节点树,如果节点树小于UNTREEIFY_THRESHOLD(6),则会重新转为链表。

get

get方法相对比较简单,在一个数据结构的实现里一般比较困难的都是插入和删除操作,这里简单说说一下:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
复制代码

get方法实际会调用getNode方法来查找元素:

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        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;
}
复制代码

getNode会先根据hash取出数组下标,然后找到对应的节点,但是hash值相同,key不一定一样,所以还会调用equals来匹配。对于TreeNode红黑树节点,则会用红黑树二叉查找树的特性快速查找,如果是普通链表节点则只能依次遍历匹配了。红黑树的引入也是JDK 1.8相较于之前版本的一个改动。

remove

​ 一般来说remove是数据结构中比较难实现的方法。在HashMap中,调用remove方法实际会调用removeNode方法进行操作:

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))))
            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;
}
复制代码

HashMapremove方法看起来并不复杂,首先会查找节点是否存在,如果找到了对应的节点则会进行删除。如果是链表类型的节点,删除逻辑会比较简单,改动一下引用即可。实际上HashMap比较复杂的插入删除逻辑在TreeNode红黑树节点里,这里就不再细说了。

遍历

HashMap可以通过keySetvaluesentrySet三种方式遍历,keySet方法直接遍历

HashMap中存储的key,values则遍历HashMap存储的value,entrySet则会取出HashMap中存储的各个节点信息。

final class KeyIterator extends HashIterator
    implements Iterator<K> {
    public final K next() { return nextNode().key; }
}

final class ValueIterator extends HashIterator
    implements Iterator<V> {
    public final V next() { return nextNode().value; }
}

final class EntryIterator extends HashIterator
    implements Iterator<Map.Entry<K,V>> {
    public final Map.Entry<K,V> next() { return nextNode(); }
}
复制代码

​ 通过观察源码,我们会发现这三个方法的迭代器的next方法的实现都是基于nextNode方法,因此我们只需要关心这个方法的实现即可。

final Node<K,V> nextNode() {
    Node<K,V>[] t;
    Node<K,V> e = next;
  //检查并发修改
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    if (e == null)
        throw new NoSuchElementException();
  //如果链表节点的下一个不为空取next
    if ((next = (current = e).next) == null && (t = table) != null) {
      //从数组中取下一个节点
        do {} while (index < t.length && (next = t[index++]) == null);
    }
    return e;
}
复制代码

​ 遍历方法也不复杂,首先判断是否并发修改,然后判断当前的节点是否为空。如果不为空,则尝试获取该节点的下一个元素,如果获取不到,则遍历数组尝试找到下一个不为空的节点返回。

​ 为什么遍历HashMap时候不用迭代器的remove方法删除元素,而是调用HashMapremove方法会造成并发修改异常(ConcurrentModificationException)呢?首先迭代器初始化的时候会保存当前的HashMapmodCountexpectedModCount,每次遍历到下一个元素都会进行检测,如果直接调用了HashMapremove方法,modCount会发生改变,但是迭代器里的expectedModCount不会发生改变,这时候就会出现异常。而调用迭代器的remove方法删除元素,会在完成删除操作后更新expectedModCount,这样就不会出现错误。

性能分析

​ JDK中最常用的两个基础Map就是HashMapTreeMap了,那我们通常该如何选择呢?

​ 首先HashMap的实现是基于数组+链表+红黑树实现。通过散列函数计算下标,可以快速的在数组中获取到根节点。较短的节点采用链表存储,插入和删除效率都比较高。对于比较长的节点采用红黑树存储,可以解决链表过长时候查询效率低下的问题。

​ 而TreeMap底层采用了红黑树来实现,红黑树是一个平衡二叉查找树,因此我们存储的key需要是可以排序的。存储的元素通过中序遍历可以有序的获取。但是因为红黑树需要实现平衡,在插入和删除操作都需要进行一些操作来保持,因此会有一定的性能损耗。

​ 因此,当我们不需要关心存储的元素的顺序的时候,我们基本都可以选择HashMap,而如果我们需要获取排序后的元素,我们可以选择TreeMap

线程安全

HashMap不是线程安全的,在不同的线程对HashMap进行操作会有线程安全问题。如果需要多个线程操作,我们应该选择ConcurrentHashMapConcurrentHashMap会对putremove操作加锁,但是get方法不会,因此读操作不会受到性能影响。

文章分类
Android
文章标签