Java容器源码分析之HashMap与HashSet

HashMap与HashSet介绍

HashMap是Java为我们提供的一个存放键值对的容器,通过键的hash值定位存储位置,具有很快的访问速度。但是其遍历顺序是不确定的,这里所说的不确定是指其遍历的顺序与放入顺序不一致、多次遍历输出顺序不一致(可能会放进数据导致reHash,改变原有顺序)。HashMap允许有一个null的键,对于值没做要求。HashMap也是个非线程安全的容器,在并发环境下可以使用ynchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。

HashSet是Java为我们提供的一个集合类容器,他不允许容器内元素重复,底层采用HashMap实现(适配器模式)。

以下是HashMap的UML图与HashSet的Uml图:

HashMap UML图

HashSet UML图

HashMap源码分析

HashMap数据结构

HashMap采用了一种数组+链表+红黑树(JDK1.8新增)的数据结构;

HashMap数据结构

HashMap实际存放数据的是一个叫Entry的Node节点,它的定义如下:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

存入HashMap里的数据均用Entry封装起来,通过键值定位到数组的位置,当该位置为空时,放入Entry元素,当该位置不为空时通过equals比较该位置与放入元素的键值,当键值相等时直接替换,当键值不相等时,采用拉链法解决hash冲突。

拉链法解决hash冲突:当hash冲突发生时,通过在该位置建立链表,将所有hash值相同但是equals不相等的元素通过链表链起来解决hash冲突;

当HashMap中拉链的长度超过8时,HashMap将链表数据结构转换为红黑树,此时的查找时间复杂度降低为O(log n)。

HashMap方法解析

get方法

HashMap get方法源码如下:

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

首先先拿到key的hash值,然后通过hash值定位到数组中该hash位置的node,然后返回node的value;

计算key的hash值方法hash():

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

计算一个key的hash值得具体算法是:当key为null时,hash值为0,不为null时,首先得到该key的hashCode,然后异或上key的hashCode值无符号右移16位。

通过hash值得到数组指定位置node元素方法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;
}

首先,通过(n - 1) & hash得到元素在数组中的位置然后取出元素,其中n是数组的总长度;当这个元素与要取的key相等(==与equals)时,该元素就是要取得元素;当不相等时,即判断为hash冲突了;当hash冲突时,HashMap通过查找链表(红黑树)的方式查找该位置的拉链(红黑树),直到找到一个元素与key equals相等时返回或者没找到返回null,此时的查找就变为对链表或红黑树的遍历。

put方法

HashMap put方法源码如下:

public V put(K key, V value) {
    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)
        //没有初始化,开始初始化table数组
        n = (tab = resize()).length;
    //通过(n - 1) & hash获取该元素在table中该放入的位置
    if ((p = tab[i = (n - 1) & hash]) == null)
        //若该位置为空,则直接放入元素
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        //该位置不为空时,开始判断是否是hash冲突
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //不是hash冲突,开始判断该位置的数据结构是否是红黑树
        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) // -1 for 1st
                        //当链表长度超过临界值时,开始将链表转换为红黑树数据结构
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    //当遍历到链表的某一个位置时,该位置元素与要插入元素equals相等时即判断两个元素相等,不用插入,跳出循环
                    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;
    if (++size > threshold)
        //当此时HashMap长度大于长度 * 扩容因子时扩容
        resize();
    afterNodeInsertion(evict);
    return null;
}

HashMap在put一个元素的时候会去调用其putVal方法,在putVal方法中,首先会去检测用来存储元素的数组是否已经初始化了,因为HashMap与ArrayList一样采用了延迟为数组分配空间的策略,我们可以来看下它的构造方法:

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

默认的构造方法中,就设置了一个扩容因子。

若数组已经初始化了,HashMap会通过(n - 1) & hash计算出该元素在数组中的位置,当该位置没有元素时,直接放入,当该位置已经有元素了,HashMap开始判断是否与该位置的元素发生了Hash冲突。

当发生了Hash冲突时,HashMap会判断该位置是通过红黑树还是链表,然后通过红黑树或者链表添加数据的方式添加新元素。

最后HashMap会检查此时数组的长度,当长度超过数组容量*扩容因子时会开始扩容的过程。

HashMap扩容函数resize方法:

//resize兼容了扩容和HashMap初始化的过程
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    //获取数组长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //获取临界长度
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        //当现在数组长度大于0时即说明HashMap已经初始化了,开始扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            //当长度超过1<<30长度时,直接将新长度设置为Integer.MAX_VALUE,为1<<30的两倍
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
            //当(oldCap << 1) < MAXIMUM_CAPACITY且oldCap大于默认长度时,直接扩容为原有长度的两倍
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0)
        //当数组没有初始化时,若oldThr>0,则说明调用的是有参数的构造函数
        //此时新长度就是带参数构造函数计算出来的临界长度
        newCap = oldThr;
    else {
        //数组未初始化,且调用的是没有参数构造函数
        //数组长度为默认值1<<4
        //计算临界长度
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        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 { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //通过e.hash & oldCap判断元素是否需要移动
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                //确定首元素
                                loHead = e;
                            else
                                //循环遍历将元素迁移至loHead为首的链表中
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        //loTail != null不为空说明元素不用移动,直接放在数组原位置
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        //hiTail != null说明元素需要移动,将链表放在j + oldCap新位置
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    //完成扩容
    return newTab;
}

这里需要讲一下的,扩容时通过e.hash & oldCap判断元素是否需要移动,具体示例如下:

  • 示例1:

    • 通过e.hash & oldCap判断元素是否需要移动:
      e.hash = 10 0000 1010
      oldCap = 16 0001 0000
      & = 0 0000 0000 //不需要移动
    • 通过(e.hash & (oldCap-1))计算得到的下标位置:
      e.hash = 10 0000 1010
      oldCap - 1 = 15 0000 1111
      & = 10 0000 1010 //位置没变
  • 示例2:

    • 通过e.hash & oldCap判断元素是否需要移动:
      e.hash = 17 0001 0001
      oldCap = 16 0001 0000
      & = 1 0001 0000 //元素需要移动,新的下标位置是原下标位置 + 原数组长度
    • 通过(e.hash & (oldCap-1))计算得到的下标位置:
      e.hash = 17 0001 0001
      newCap - 1 = 31 0001 1111
      & = 17 0001 0001 //位置为17

HashSet源码分析

HashSet是通过HashMap实现的,首先我们来看下Hashset的构造函数:

public HashSet() {
    map = new HashMap<>();
}

public HashSet(Collection<? extends E> c) {
    map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
    addAll(c);
}

public HashSet(int initialCapacity, float loadFactor) {
    map = new HashMap<>(initialCapacity, loadFactor);
}

public HashSet(int initialCapacity) {
    map = new HashMap<>(initialCapacity);
}

HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

通过HashSet的构造函数我们就能略知一二,HashSet有个map的成员变量保存了一个HashMap的实例。

HashSet的add方法:

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

HashSet的add方法很简单,就一行代码;成员变量PRESENT是new出来的一个static final的Object对象。

HashSet通过将值作为HashMap的key,一个static final的Object对象作为value用来实现了一个不可重复的set容器

思考

  • Q:为什么HashMap与HashSet容量总是2的n次方?
    A:HashMap通过h & (table.length -1)来得到该对象的保存位,当长度总是2的n次方时,h & (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。

  • Q:为什么HashMap要对长度取模?
    A:把hash值对数组长度取模运算,可以使元素相对均匀分布在数组中。

posted @ 2020-11-05 23:12  Sirius-  阅读(144)  评论(0编辑  收藏  举报