• HashMap与LinkedHashMap


    HashMap与LinkedHashMap

    1. 简介

    在日常开发中我们经常会批量操作数据,因此很多高级语言除了提供数组,还给我们提供很多高级的、抽象的数据类型来让我们处理批量数据时得心应手。由于这些轮子对于程序的性能是比较关键的轮子,因此很多语言都内置的提供了比较精致的实现。在java中,这种实现被称为集合框架。集合框架包含的接口、类十分丰富,而且功能强大,因此理解并熟悉java集合框架,对于写出正确高效的程序是十分有必要的。java集合框架中包含两个重要的类LinkedHashMapHashMap,它们常常被用于按key-value存储、操作数据,对于常见的操作都是常数的时间复杂度,因此被广泛使用,虽然这两个类的作用类似,但是他们的实现和使用场景稍微不同。

    2. 二者的区别

    HashMapLinkedHashMap都实现了Map接口,二者的存储形式都是采用bucket加链表的形式来进行存储的。二者的主要区别:

    • HashMap由于是按照keyhash值映射到对应的bucket中,无法保证遍历HashMap时的顺序是预期的顺序
    • LinkedHashMapHashMap的基础上加以改进,却可以保证遍历的顺序要么是插入item的顺序或者LRU访问的顺序

    这是因为LinkedHashMap维护了一个双向链表来记录数据插入的顺序,因此在迭代遍历生成的迭代器的时候,是按照双向链表的路径进行遍历的。

    如果选择LRU访问的顺序,LinkedHashMap对于访问过的item会将其移动到双链表的末尾,这样保证最近访问过的item是处于链表末端,因此较老其不经常使用的item会处于链表前端。这个特性恰好符合LRU的思想,因此LinkedHashMap可以用来实现LRU CacheAndroid提供的SDKLruCache类便是利用LinkedHashMap实现了基于Lru规则的缓存功能。

    另外可以发现在java8HashMapLinkedHashMap有了改动,据说在某些Hash碰撞严重时,性能也不会太差。java8之前的Map实现的问题是当出现某个bucket的后面的链表太长了,也就是说发生hash冲突的item太多了,这样会导致访问操作退化到了O(n)

    java8的改进便是当bucket的链表长度大于阈值的时候,会将链表重新组织为一颗红黑树,这样在hash碰撞严重的时候性能还是可以保证到log(n).改进前后的示意图如下所示:

    这里写图片描述

    这里写图片描述

    在使用LinkedHashMapHashMap的时候应该注意Keyhash值是怎么取得,如果不同的key经常出现相同的hash值,则会频繁出现冲突,降低性能。

    同时,由于改进后的HashMap会在某个bucket后的链表长度超过某个阈值时,重新将连边组织为一颗红黑树,因此在java8上的key最好实现Comparable接口来保证key是可以通过compareTo进行比较的,因为这样会简化建立红黑树的判断流程,提高效率。当然如果不实现Comparable接口的话,也会有相应的方法保证hash值冲突的item形成一颗平衡的红黑树。

    3. 源码阅读

    此处选取几个关键的地方进行源码分析:

    1. 对于HashMap重点关注这几个方法
    final void treeifyBin(Node<K,V>[] tab, int hash)
    public V put(K key, V value)
    final void treeify(Node<K,V>[] tab)
    static int tieBreakOrder(Object a, Object b)
    
    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;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //通过hash找到对应的桶,如果桶空则直接新建一个链表节点置于桶中并成为链表头
    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的匹配规则,首先是 == 匹配,然后再是调用equals方法匹配
            e = p;
        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) {//如果查找到了链表尾,认为匹配到key,则新建一个节点
                    p.next = newNode(hash, key, value, null);
                    //java8改进的地方!!!!如果桶内链表的长度大于了阈值则树形化该链表
                    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))))//匹配到一个存在的item
                    break;
                p = e;
            }
        }
        //如果成功匹配了key,则此次put操作仅仅是修改value而没有插入新的节点
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);//访问元素e的回调,注意比较LinkedHashMap在此处的实现。
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)//如果真个hashmap的长度超过了阈值,就说明可能会出现严重的hash冲突此时就应该resize(),rehash。
        resize();
    afterNodeInsertion(evict);//插入元素e的回调,注意比较LinkedHashMap在此处的实现。
    return null;
    }
    
    
    /**
    * Replaces all linked nodes in bin at index for given hash unless
    * table is too small, in which case resizes instead.
    */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //如果Hash表的桶数小于可以树形化的阈值,则只是扩大桶数,进行再Hash
    //我估计在设计的时候权衡了重建一颗红黑树和再Hash的cost,当桶的数量很少的时候,再hash划算一些
    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 {
            //从链表头hd,将每一个节点转化为一个树节点且继续保持链表的顺序
            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);//最终树形化链表在这里完成
    }
    }
    
    
    /**
     * Forms tree of the nodes linked from this node.
     * @return root of tree
     */
    final void treeify(Node<K,V>[] tab) {
        TreeNode<K,V> root = null;
        //以当前对象作为root,开始构建一颗红黑树
        for (TreeNode<K,V> x = this, next; x != null; x = next) {
            next = (TreeNode<K,V>)x.next;
            x.left = x.right = null;
            if (root == null) {
                x.parent = null;
                x.red = false;
                root = x;
            }
            else {
                K k = x.key;//当前树节点x包含的key
                int h = x.hash;//当前树节x点的hash值
                Class<?> kc = null;//x节点包含的key的类
                //从红黑树的根节点开始寻找合适的插入的位置,然后再平衡该树。
                for (TreeNode<K,V> p = root;;) {
                    int dir, ph;
                    K pk = p.key;
                    //正常来讲,由于HashMap是树形化一个桶内的链表,因此
                    //每个链表的节点的hashCode()返回的值应该是一样的。
                    //因此这里两个分支(ph = p.hash) > h 和 ph < h应该都不会被执行
                    //这里会直接进入分支3进行判断
                    if ((ph = p.hash) > h)//分支1
                        dir = -1;
                    else if (ph < h)//分支2
                        dir = 1;
                       //分支3 当两个节点的hash值相等的时候(事实上绝大部分都是这样的情况)
                        //则反射判断x节点的key的类是否是实现了Comparable接口,如果实现了则利用compareTo方法进行比较判断,从而决定插入的位置;
                        //如果没有实现Comparable接口或者实现了Comparable接口但是compareTo比较的结果还是一致,则利用tieBreakOrder来决定大小。
                        //因为红黑树的节点都要有大小区分的,不能出现大小相同的节点,因此无论采用哪种量化方式,一定得比较个大小出来。
                    else if ((kc == null &&
                              (kc = comparableClassFor(k)) == null) ||
                             (dir = compareComparables(kc, k, pk)) == 0)
                        dir = tieBreakOrder(k, pk);//当俩节点hashCode返回值相等且没有实现comparable接口,在这种尴尬僵持的局面,就需要调用tieBreakOrder方法
                    //来一较高低了。因此java8中对于HashMap的文档中建议Key要实现Comparable接口,这样此处就不会进入到如此尴尬僵持的局面,会提高些许性能,毕竟后面
                    //打破僵局是需要付出代价的
    
                    TreeNode<K,V> xp = p;
                    //直到走到叶节点,则进行插入
                    if ((p = (dir <= 0) ? p.left : p.right) == null) {
                        x.parent = xp;
                        if (dir <= 0)
                            xp.left = x;//如果该节点x比父亲节点p小,则作为节点p的左孩子
                        else
                            xp.right = x;
                        root = balanceInsertion(root, x);//红黑树都有的操作,插入节点后需要进行调整以继续保证红黑树的性质
                        break;
                    }
                }
            }
        }
        moveRootToFront(tab, root);//保证桶内存放的是红黑树的根节点root
    }
    
    /**
     * Forms tree of the nodes linked from this node.
     * @return root of tree
     */
    final void treeify(Node<K,V>[] tab) {
        TreeNode<K,V> root = null;
        //以当前对象作为root,开始构建一颗红黑树
        for (TreeNode<K,V> x = this, next; x != null; x = next) {
            next = (TreeNode<K,V>)x.next;
            x.left = x.right = null;
            if (root == null) {
                x.parent = null;
                x.red = false;
                root = x;
            }
            else {
                K k = x.key;//当前树节点x包含的key
                int h = x.hash;//当前树节x点的hash值
                Class<?> kc = null;//x节点包含的key的类
                //从红黑树的根节点开始寻找合适的插入的位置,然后再平衡该树。
                for (TreeNode<K,V> p = root;;) {
                    int dir, ph;
                    K pk = p.key;
                    //正常来讲,由于HashMap是树形化一个桶内的链表,因此
                    //每个链表的节点的hashCode()返回的值应该是一样的。
                    //因此这里两个分支(ph = p.hash) > h 和 ph < h应该都不会被执行
                    //这里会直接进入分支3进行判断
                    if ((ph = p.hash) > h)//分支1
                        dir = -1;
                    else if (ph < h)//分支2
                        dir = 1;
                       //分支3 当两个节点的hash值相等的时候(事实上绝大部分都是这样的情况)
                        //则反射判断x节点的key的类是否是实现了Comparable接口,如果实现了则利用compareTo方法进行比较判断,从而决定插入的位置;
                        //如果没有实现Comparable接口或者实现了Comparable接口但是compareTo比较的结果还是一致,则利用tieBreakOrder来决定大小。
                        //因为红黑树的节点都要有大小区分的,不能出现大小相同的节点,因此无论采用哪种量化方式,一定得比较个大小出来。
                    else if ((kc == null &&
                              (kc = comparableClassFor(k)) == null) ||
                             (dir = compareComparables(kc, k, pk)) == 0)
                        dir = tieBreakOrder(k, pk);//当俩节点hashCode返回值相等且没有实现comparable接口,在这种尴尬僵持的局面,就需要调用tieBreakOrder方法
                    //来一较高低了。因此java8中对于HashMap的文档中建议Key要实现Comparable接口,这样此处就不会进入到如此尴尬僵持的局面,会提高些许性能,毕竟后面
                    //打破僵局是需要付出代价的
    
                    TreeNode<K,V> xp = p;
                    //直到走到叶节点,则进行插入
                    if ((p = (dir <= 0) ? p.left : p.right) == null) {
                        x.parent = xp;
                        if (dir <= 0)
                            xp.left = x;//如果该节点x比父亲节点p小,则作为节点p的左孩子
                        else
                            xp.right = x;
                        root = balanceInsertion(root, x);//红黑树都有的操作,插入节点后需要进行调整以继续保证红黑树的性质
                        break;
                    }
                }
            }
        }
        moveRootToFront(tab, root);//保证桶内存放的是红黑树的根节点root
    }
    
    
    
    /**
     * Tie-breaking utility for ordering insertions when equal
     * hashCodes and non-comparable. We don't require a total
     * order, just a consistent insertion rule to maintain
     * equivalence across rebalancings. Tie-breaking further than
     * necessary simplifies testing a bit.
     */
    static int tieBreakOrder(Object a, Object b) {
        int d;
        if (a == null || b == null ||
            (d = a.getClass().getName().
             compareTo(b.getClass().getName())) == 0)//先利用两对象的类名的大小比较,若仍然陷入僵局,则调用
            //System.identityHashCode()的方法该方法会返回对象唯一的真实的hash值无论对象的类是否重写了hashCode方法
            d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
                 -1 : 1);
        return d;
    }
    

    以上部分简要的分析了HashMap的改进处的源码。

    1. 对于LinkedHashMap,主要阅读分析其如何保证迭代的顺序具有LRU的性质
      LinkedHashMapHashMap的子类,只是稍加改造便使得其具有链表的顺序性质。
    
    public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>
    
    
    /**
    * The head (eldest) of the doubly linked list.
    */
    transient LinkedHashMap.Entry<K,V> head;//维护的双向链表的
    /**
    * The tail (youngest) of the doubly linked list.
    */
    transient LinkedHashMap.Entry<K,V> tail;//维护的双向链表的表尾
    
    /**
    * The iteration ordering method for this linked hash map: <tt>true</tt>
    * for access-order, <tt>false</tt> for insertion-order.
    *
    * @serial
    */
    final boolean accessOrder;//是访问顺序还是插入顺序

    主要关注以下几个方法:

    Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) 
    private void linkNodeLast(LinkedHashMap.Entry<K,V> p)
    
    
    void afterNodeAccess(Node<K,V> e)
    
    
    void afterNodeInsertion(boolean evict)
    
    
    protected boolean removeEldestEntry(Map.Entry<K,V> eldest)
    
    //这个方法是HashMap的hook方法,只是简单的扩展了HashMap的Node类,就完成了LinkedHashMap的大部分功能
    //不得不说类的设计很巧妙
    Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<K,V>(hash, key, value, e);//将节点e
    linkNodeLast(p);//将该Entry链接到LinkedHashMap维护的双向链表的表尾
    return p;
    }
    /**
    * HashMap.Node subclass for normal LinkedHashMap entries.
    */
    static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
    }
    
    // link at the end of list
    private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;//tail是维护的双链表的表尾
    tail = p;
    //接下就是简单的链表链接操作
    if (last == null)
        head = p;
    else {
        p.before = last;
        last.after = p;
    }
    }

    由于LinkedHashMap没有重写put方法,因此它复用了HashMapput方法,只是简单重写了hook方法newNode。因此put方法不用分析了。到此应该就可以去看迭代器的实现了,讲道理的话应该是按照双向链表的顺序来迭代的。

    
    abstract class LinkedHashIterator {
    LinkedHashMap.Entry<K,V> next;
    LinkedHashMap.Entry<K,V> current;
    int expectedModCount;
    
    LinkedHashIterator() {
        next = head;//head存放的是双向链表的表头
        expectedModCount = modCount;
        current = null;
    }
    
    public final boolean hasNext() {
        return next != null;
    }
    
    final LinkedHashMap.Entry<K,V> nextNode() {
        LinkedHashMap.Entry<K,V> e = next;
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        if (e == null)
            throw new NoSuchElementException();
        current = e;
        next = e.after;//依次迭代
        return e;
    }
    }
    

    自此已经大致清楚了LinkedHashMap是如何简单的改造了HashMap而拥有了顺序的迭代。

    LinkedHashMap不仅仅遍历是有序的,而且还可以选择是何种顺序,是插入顺序还是访问顺序

    acceeOrder的定义了遍历是何种顺序,该值默认是false,若想为true,需要显示的指定。

    
    void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    //如果是访问顺序,则将节点e放在链表尾部
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        p.after = null;
        if (b == null)
            head = a;
        else
            b.after = a;
        if (a != null)
            a.before = b;
        else
            last = b;
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
        tail = p;
        ++modCount;
    }
    }
    

    这样无论是put还是get操作都会导致该元素e会被放在链表尾部。这样链表表头部分的元素的访问时间就相对久远了,这个特性就恰恰比较符合LRU的思想。因此当LinkiedHashMap的元素个数超过一定的阈值时(因为缓存的容量是有限的),就需要删除某些缓存item了。在LinkedHashMap中就有这样的CallBack来完成这个目的。

    
    void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    //如果该LinkedHashMap允许删除老元素,则移除老元素
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
    }
    

    LinkedHashMap默认是不移除老元素,因此要实现Lru需要重写该方法。

    
    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
            return false;
    }
    
    

    通常会这么重写

    private static final int MAX_ENTRIES = 100;
    
    protected boolean removeEldestEntry(Map.Entry eldest) {
            return size() > MAX_ENTRIES;
    }
    

    可以看出来LinkedHashMap只是稍微加以改进,就具备了额外的功能。这种类的设计十分精致,值得借鉴。
    HashMap预留了几个关键的hook方法给扩展类(此处是LinkedHashMap),例如newNode(),afterAccess() afterInsertion()等。这样就是策略和机制分离了,便于扩展类添加更丰富的功能。当然我们也可以按照需要扩展HashMap,从而改变其某些行为。

    但是HashMap类中的TreeNode怎么会去继承子类LinkedHashMapEntry,难道仅仅是为了少些几行代码?我觉得这个设计不是很好。毕竟父类不应该去获取子类的某些信息,有点本末倒置。

    /**
    * 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<K,V> extends LinkedHashMap.Entry<K,V> {
    

    4. Best Practices

    • 在使用HashMap时应该尽量保证keyhashCode返回值分布均匀性;在java8上使用时key应该尽量实现comparable接口。

      • LinkedHashMapHashMap性能的比较:在基本的put get remove操作,两者的性能几乎相近,由于LinkedHashMap维护着一个双向链表,因此性能可能稍微差一点点。但是在迭代遍历的时候,因为LinkedHashMap遍历的所有的插入的元素,而HashMap是遍历的整个HashMap(包括一些空桶),因此LinkedHashMap的性能稍微优于HashMap

      • LinkedHashMap可以保持任意的Map的顺序信息,就像这样使用:

      
      void foo(Map m) {
           Map copy = new LinkedHashMap(m);
           ...
      }
  • 相关阅读:
    hph 缓存机制
    递归调用 和 迭代
    多维数组排序
    php curl操作
    JavaScript基本数据类型
    JavaScript基础
    CSS基础布局
    CSS基础样式
    CSS选择器
    CSS3基础
  • 原文地址:https://www.cnblogs.com/Spground/p/8536148.html
Copyright © 2020-2023  润新知