Java 深入分析 - 容器 Map 与 Set

小概

Java 深入分析 - 容器 Map 与 Set_第1张图片

Map 主要定义以下行为规范

- put(key, value)
- get(key)
- keySet()
- values()
- entrySet()

通过往 Map 里放置一对一对 entry(键值对),查询时,我们可以通过 key 来快速找到 value,更可以一次性获取全部 key - keySet()value - values() 或者 entry - entrySet()

Map 接口中,又定义 Entry 抽象概念,并将实现交给子类

    /**
     * A map entry (key-value pair).  The Map.entrySet method returns
     * a collection-view of the map, whose elements are of this class.  The
     * only way to obtain a reference to a map entry is from the
     * iterator of this collection-view.  These Map.Entry objects are
     * valid only for the duration of the iteration; more formally,
     * the behavior of a map entry is undefined if the backing map has been
     * modified after the entry was returned by the iterator, except through
     * the setValue operation on the map entry.
     *
     * @see Map#entrySet()
     * @since 1.2
     */
    interface Entry {
        K getKey();

        V getValue();

        V setValue(V value);

        boolean equals(Object o);

        int hashCode();
    }

Set 本身就代表集合,容器内元素唯一,完全继承自
Collection,只能通过 iterator 访问

SortedMapSortedSet 需使用 Comparator 使 key 维持特定顺序,由此有序 key 将能够 定位首尾 和被 截取

- comparator()
- sub(K fromKey, K toKey)
- head(K toKey)
- tail(K fromKey)
- firstKey()
- lastKey()

NavigableMapNavigableSet 具备 导航 功能,能够通过给定 key 定位邻居

- K lower(K k)
- K floor(K k)
- K ceiling(K k)
- K higher(K k)

HashMap

HashMap 作为 哈希表 [1],是使用最频繁的容器之一,总体特征如下

  1. 自扩容
  2. 数组构建槽
  3. 重哈希
  4. 链表、红黑树处理碰撞
  5. 插入元素无序

在其内部,维护一个 数组 作为整张哈希表的槽,对 Map 接口中 Entry 接口给出实现

    /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node[] table;

    /**
     * Holds cached entrySet(). Note that AbstractMap fields are used
     * for keySet() and values().
     */
    transient Set> entrySet;

    /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
    static class Node implements Map.Entry {
        final int hash;
        final K key;
        V value;
        Node next;
        // ...
    }

映射与优化

我们现在考虑 余数法 来给入槽元素定位槽索引,Java 中可以利用对象的 hashCode() 方法,来很方便地设计映射函数

但计算机取余是一步很费性能的操作,HashMap 是如下简化的

n2 的倍数 时,正好构成 低位掩码,(n - 1) 二进制i位以下全为 1, 以上全为 0,我们以 n = 16, key = 23 作如下例子

通过这个图例我们可以看出,这个操作就是取余,不仅简单,而且如此设计对后面还有帮助

根据上述映射方法,现在我们考虑如下情况,并讨论如何均匀映射

  1. 若设计成 bucketCapacity = 2^i,现在假设有一个数据序列,若它们低i位 全部相等,那么不管它们是什么数,都将映射入同一个槽产生很多碰撞
  2. key 对象封装者 hashCode() 写得不一定很有水平,映射得非常集中也不是没可能

因此 HashMap 很有必要对 key.hashCode() 进行 二次哈希,采取如下方式,key 对象 高半区和低半区做异或,混合原始哈希码的高位和低位,以此来加大低位的随机性,将二次哈希后的哈希值映射得非常均匀

    /**
     * Computes key.hashCode() and spreads (XORs) higher bits of hash
     * to lower.  Because the table uses power-of-two masking, sets of
     * hashes that vary only in bits above the current mask will
     * always collide. (Among known examples are sets of Float keys
     * holding consecutive whole numbers in small tables.)  So we
     * apply a transform that spreads the impact of higher bits
     * downward. There is a tradeoff between speed, utility, and
     * quality of bit-spreading. Because many common sets of hashes
     * are already reasonably distributed (so don't benefit from
     * spreading), and because we use trees to handle large sets of
     * collisions in bins, we just XOR some shifted bits in the
     * cheapest possible way to reduce systematic lossage, as well as
     * to incorporate impact of the highest bits that would otherwise
     * never be used in index calculations because of table bounds.
     */
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

值得注意的是,空键 将全被映射入 0 号槽

碰撞处理与优化

内接链表

内部封装类 Node 本身就是一种链表节点,也就是说

  1. 入槽:发现槽内已经有元素,那么顺着链表判断,直到检查到一个节点的 nextnull,便在其 next 后接一个链表
  2. 搜索:找到映射槽,顺着链表用 equals 搜索

Treefy

不管映射得再均匀,扩容扩得再好,若是很多个元素仍然碰撞在同一个槽,我们会发现这个槽后面将接一串 非常长的链表,此时对元素的搜索代价将是十分惨痛的,因此当链表到达一定长度时,有理由替换成另一种数据结构,HashMap 中采用的是平衡搜索树的一种 - 红黑树 [2]

    /**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

    /**
     * Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
     * extends Node) so can be used as extension of either regular or
     * linked node.
     */
    static final class TreeNode extends LinkedHashMap.Entry {
        TreeNode parent;  // red-black tree links
        TreeNode left;
        TreeNode right;
        TreeNode prev;    // needed to unlink next upon deletion
        boolean red;
        // ...
    }

也就是说,当一个槽内如果有超过 8 根链表,会将该槽节点 树化,并用树根节点来替换,对树节点的操作与红黑树一致

    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
        treeifyBin(tab, hash);

扩容策略

就算映射得再均匀,bucketCapacity 太小也将会产生大量碰撞,因此扩容将变得十分有必要

    /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * The load factor for the hash table.
     *
     * @serial
     */
    final float loadFactor;

    /**
     * The next size value at which to resize (capacity * load factor).
     *
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    int threshold;

考虑如下扩容算式

并如下扩容

    if (bucketCapacity > threshold && bucket[bucketIndex] != null) {
        newBucketCapacity = getNewCapacity();
    }

HashMap 中,默认为 初始容量 16扩容阈值因子 0.75每次扩容 2 倍,最大容量 1 << 30

重哈希

但是,HashMap 每次扩容后,槽容量变为两倍,这也就意味着扩容前的映射全部失效,那么现在要考虑,将之前存储的元素 重新映射 到新槽中

现在 bucketCapacity = 2^i 又有了用武之地,我们考虑槽容量为 16,现在要为它扩容的情况

Java 深入分析 - 容器 Map 与 Set_第2张图片

扩容两倍后

Java 深入分析 - 容器 Map 与 Set_第3张图片

我们从图例中可以很明确的观察到,重哈希后,只需要关注第i+1位即可,所以重哈希有如下几种情况

  1. 无节点:不处理
  2. 单节点:要么还是在原位置,要么在原位置 +odlCapacity 位置
  3. 链表:遍历各个节点,每个节点的处理方式跟单节点一样
  4. 树:从树根节点开始处理

because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table.

LinkedHashMap

LinkedHashMap 具有以下特征

  1. 完全继承自 HashMap
  2. 内部维护一个 双端链表 记录插入顺序
  3. 迭代顺序与插入顺序有关
public class LinkedHashMap extends HashMap
    implements Map {
    /**
     * The head (eldest) of the doubly linked list.
     */
    transient LinkedHashMap.Entry head;

    /**
     * The tail (youngest) of the doubly linked list.
     */
    transient LinkedHashMap.Entry tail;

    /**
     * HashMap.Node subclass for normal LinkedHashMap entries.
     */
    static class Entry extends HashMap.Node {
        Entry before, after;
        // ...
    }
}

为此 HashMap 中专门暴露出了几个方法,给子类 重写,并会在 non-tree Node 节点进行操作时 回调 这些方法

    // Callbacks to allow LinkedHashMap post-actions
    void afterNodeAccess(Node p) { }
    void afterNodeInsertion(boolean evict) { }
    void afterNodeRemoval(Node p) { }

对于 TreeNode 节点的操作,也全部被 LinkedHashMap 重写,节点改变时在双端链表中作出相应记录

TreeMap

TreeMap 具有以下特征

  1. 红黑树 支持搜索,key 顺序与其 Comparator 有关
  2. 迭代顺序和 key 顺序有关
public class TreeMap extends AbstractMap
    implements NavigableMap, Cloneable, java.io.Serializable
{
    /**
     * The comparator used to maintain order in this tree map, or
     * null if it uses the natural ordering of its keys.
     *
     * @serial
     */
    private final Comparator comparator;

    private transient Entry root;

    /**
     * Fields initialized to contain an instance of the entry set view
     * the first time this view is requested.  Views are stateless, so
     * there's no reason to create more than one.
     */
    private transient EntrySet entrySet;
    private transient KeySet navigableKeySet;
    private transient NavigableMap descendingMap;

    /**
     * Node in the Tree.  Doubles as a means to pass key-value pairs back to
     * user (see Map.Entry).
     */
    static final class Entry implements Map.Entry {
        K key;
        V value;
        Entry left;
        Entry right;
        Entry parent;
        boolean color = BLACK;
    }
    // ...
}

其实只要理解了红黑树的实现原理,TreeMap 也不难,我们在这里就不细谈了

值得注意的是,与 Map 不同,NavigableMapSortedMap 是有序的,具备对不同 key 之间的一些搜索功能,其中的截取功能和 ArrayList 中有点类似,获取的只是一份 引用地址,所以使用时需额外注意

    /**
     * This class exists solely for the sake of serialization
     * compatibility with previous releases of TreeMap that did not
     * support NavigableMap.  It translates an old-version SubMap into
     * a new-version AscendingSubMap. This class is never otherwise
     * used.
     *
     * @serial include
     */
    private class SubMap extends AbstractMap
        implements SortedMap, java.io.Serializable {
        private boolean fromStart = false, toEnd = false;
        private K fromKey, toKey;
    }

WeakHashMap

WeakHashMapEntry 继承自 弱引用 WeakReference [3],并把 key 放入队列中,也就是说,每当 GC 时发现 key 只被弱引用所引用,那么它都会被回收,回收的对象被存储在内部维护的 ReferenceQueue

因此 WeakHashMap 具有以下特征

  1. HashMap 实现大致相同
  2. GC 时会回收只被弱引用所引用的 key

值得注意的是,如果你存入 Integer 作为键,那么作为 缓存 的 -128 ~ 127 的 Entry 是永远不会被回收的,类似许多 String 也是一样,存在着许多你意想不到的比弱引用强的引用指向它

public class WeakHashMap extends AbstractMap
    implements Map {

    /**
     * Reference queue for cleared WeakEntries
     */
    private final ReferenceQueue queue = new ReferenceQueue<>();

    /**
     * The entries in this hash table extend WeakReference, using its main ref
     * field as the key.
     */
    private static class Entry extends WeakReference implements Map.Entry {
        V value;
        final int hash;
        Entry next;

        /**
         * Creates new entry.
         */
        Entry(Object key, V value,
              ReferenceQueue queue,
              int hash, Entry next) {
            super(key, queue);
            // ...
        }
    }
    // ...
}
 
 

在这里不得不讨论一个事情,如果链中间的 key 消失了,链表由此 断开 ,我们将永远找不到链后的节点,所以必须将断开的两端重新接起来

在我们对该容器进行操作时,WeakHashMap 都会将节点与 ReferenceQueue 进行 同步

  1. 重链
  2. 清空 ReferenceQueue

回收节点时,消失的是 key,但是我们能从 ReferenceQueue 中拿到消失的 Entry,每个 Entry 都保留了 hash 值,我们通过这个值定位到消失的槽,顺着链表搜索该消失的节点,将消失的节点断开,并给两端 重新链接

但让我不太理解的时,同步时,作为非线程安全的 WeakHashMap 为什么要 加锁

    /**
     * Expunges stale entries from the table.
     */
    private void expungeStaleEntries() {
        for (Object x; (x = queue.poll()) != null; ) {
            synchronized (queue) {
            // ...
            }
        }
    }

Set

Jdk 中比较常用的有 HashSetLinkedHashSetTreeSet 其中的内部实现都调用了相对应的 Map,原理大致一样,这里我们就拿 HashSet 举例子

HashSet 具有以下特性

  1. HashMap 实现
  2. 集合 元素去重

利用 HashMap 映射特性实现去重效果,Set 中的 元素被当作 key 插入 HashMap,然而 value 是什么并不关键,Jdk 中填入的是 Object,并且有一个全局常量 Object 对象 PRESENT,每次操作都与 PRESENT 相伴

    private transient HashMap map;

    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();

可能造成的隐患

equals 和 hashCode

哈希容器在元素入槽和查询搜索时,都使用了这两种方法 [4],如果我们填入的 key 对象未实现这两种方法,或者实现不正确,将可能会导致 容器效率低下 甚至 数据不正确

因此,我们应该非常小心地处理 key 对象

内存泄露隐患

假设我们有一个哈希容器 Map,我们在某个时刻给这个容器加入一对键值对,暂且将 key 记为 k,然后在另一个时刻,我们又 改变k 的某一个属性,这个属性又非常幸运地 hashCode 计算有关联

如果我们的操作是下面这种情况,那将是相当危险的

  1. 我需要靠这个 k 来找到我当时存的值,并且这个 k 是唯一的我无法再还原,由于 khashCode 已经改变,我将 永远找不到我当时存入的值
  2. 像上面所说,我通过这个 k 去找我当时存入的值,发现容器中并没有,那我又创建一个值,和 k 作为键值对存进去
  3. 恰巧我的这个 Map 是一个 全局量,并且永远不 clear(),那么 GC 永远不会回收这个 Map,由于那些错误的键值对的引用还在 Map 中,也就意味着 堆里的内存永远不会被释放

HashMap 扩容死链

非并发容器在多线程条件下是线程不安全的,但是 HashMap 不仅不安全,在扩容时还有一定几率发生 死循环,让你虚拟机趋近死亡,对为何会造成死链感兴趣可以参考这篇文章 并发的HashMap为什么会引起死循环?

如果需要支持多线程环境,千万不要用 HashMap,建议使用分段锁 ConcurrentHashMap 或者 Collections.synchronizedMap(Map)Map 转化为 Collections 内部实现支持并发的 SynchronizedMap

参考

1. Jdk 源码 1.8
2. Jdk 官方文档
3.《深入理解 Java 虚拟机》
4. JDK 源码中 HashMap 的 hash 方法原理是什么?


  1. 几分钟理解 数据结构 - 哈希表及其优化 ↩

  2. 详解TreeMap 的红黑树实现 ↩

  3. 几分钟理解 Jdk - 引用 ↩

  4. 几分钟理解 Jdk - ==,hashCodeequals

你可能感兴趣的:(Java 深入分析 - 容器 Map 与 Set)