来聊聊LinkedHashMap

什么是LinkedHashMap

LinkedHashMap继承自HashMap,在HashMap的基础上维护一条双向链表,具备了以下特点:

  1. 保持遍历顺序和插入顺序一致性。
  2. 支持按照元素访问顺序排序,适用于封装LRU缓存工具。
  3. 因为内部使用双向列表,尽管在插入和删除元素时会略微慢于 HashMap ,但在迭代访问时由于可以利用链表结构,随着元素个数增加,迭代效率会比HashMap高很多。

LinkedHashMap是在HashMap基础上在各个节点之间增加一条双向链表,使得原先散列在不同bucket、单链表、红黑树上的节点之间可以通过双向链表进行操作,实现有序关联,其逻辑结构如下图所示。

LinkedHashMap源码解析

Entry 的继承体系

在对LinkedHashMap进行讨论前,我们先来聊聊LinkedHashMap中Entry的设计,先来看一张继承体系图。 上面的继承体系图一看可能会让人产生疑惑,为什么HashMap.TreeNode不直接继承HashMap.Node而是继承LinkedHashMap.Entry呢? 其实原因很简单,我们都知道LinkedHashMap是在HashMap基础上对节点增加双向指针(before 和 after)实现双向链表的特性,所以LinkedHashMap内部链表转红黑树时,对应的节点会转为树节点TreeNode,为了保证树节点具备双向链表的特性,树节点需要继承LinkedHashMap的Entry。 但是当我们使用HashMap时,TreeNode并不需要具备组成链表能力,是不是有点浪费空间,为什么不直接在Node上面实现前驱和后继节点呢?

在HashMap.Node上直接实现前驱和后继结点,由TreeNode继承Node实现链表也是可以的。只不过这种做法会使得使用HashMap时存储键值对的节点类Node多了两个没有必要的引用,占用没必要的内存空间。

而现有的处理方式虽然在使用HashMap时,TreeNode多了两个用不到的引用,但是与TreeNode通过继承获取的组成链表的能力相比,这点浪费是值得的。引用作者都有一段注释,作者们认为使用良好的HashCode算法,HashMap链表转为红黑树的概率不大,即使转为红黑树,也可能会因为移除或者扩容,TreeNode被转换回Node,所以这点空间浪费是值得的。

java 复制代码
Because TreeNodes are about twice the size of regular nodes, we
 use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due to
removal or resizing) they are converted back to plain bins.  In
usages with well-distributed user hashCodes, tree bins are
rarely used.

构造方法

LinkedHashMap中构造方法实现比较简单,直接调用父类的构造方法完成初始化即可。这里重点聊下accessOrder,accessOrder是用于控制访问是否需要按照顺序排序,默认情况下accessOrder为false,当我们需要LinkedHashMap实现键值对按照访问顺序排序(即将最近最少访问的元素排在链表首部、最近访问的元素移动到链表尾部),需要设置accessOrder为true,可以调用构造方法public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder);将accessOrder设置为true。

java 复制代码
    public LinkedHashMap() {
        super();
        accessOrder = false;
    }
java 复制代码
    public LinkedHashMap(int initialCapacity) {
        super(initialCapacity);
        accessOrder = false;
    }
java 复制代码
       public LinkedHashMap(int initialCapacity, float loadFactor) {
        super(initialCapacity, loadFactor);
        accessOrder = false;
    }
java 复制代码
  public LinkedHashMap(Map<? extends K, ? extends V> m) {
        super();
        accessOrder = false;
        putMapEntries(m, false);
    }
java 复制代码
    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }

get方法

get方法是LinkedHashMap增删改查中唯一重写的的方法;在上文说过可以再初始化LinkedHashMap时通过accessOrder控制是否按照顺序排序,当指定accessOrder为true时,我们调用get方法,它会在元素查询完成之后,将这些方法访问的节点移动到链表的尾部。get的源码如下,执行步骤为:

  1. 调用父类方法getNode获取key,若key为null则直接返回null
  2. 如果accessOrder为true,则调用afterNodeAccess将被访问节点移动到链表最后
  3. 返回value值
java 复制代码
public V get(Object key) {
    Node<K,V> e;
    //获取key,若key为null则返回null
    if ((e = getNode(hash(key), key)) == null)
        return null;
    // 如果 accessOrder 为 true,则调用 afterNodeAccess 将被访问节点移动到链表最后
    if (accessOrder)
        afterNodeAccess(e);
    //返回value值  
    return e.value;
}

afterNodeAccess方法主要分为以下几个步骤:

  1. 如果accessOrder为true,且当前节点不为尾节点,则需要将当前节点移动到链表尾部
  2. 获取当前节点、以及前驱节点b和后继节点a
  3. 将当前节点p的后继指针设置为null,使其后继节点断开联系
  4. 若前驱节点为null,则说明当前节点p为头结点,则将头结点指向后继节点a,反之则将前驱节点指向后继节点
  5. 若后继节点不为null,则让后继节点指向前驱节点,从而建立联系。
  6. 至此节点p得以独立,只需判断尾节点last是否为空,若为空则说明该链表只有一个节点p,头尾节点皆指向节点p;若不为空则将节点p移至链表尾部。
java 复制代码
void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    //如果accessOrder为true,且当前节点不为尾节点
    if (accessOrder && (last = tail) != e) {
    //获取当前节点、以及前驱节点和后继节点
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        p.after = null;
        // 如果 b 为 null,表明 p 为头节点,估将后继节点a设置为头结点
        if (b == null)
            head = a;
        else
        //若b不为null,则将b的后继指针指向后继节点a
            b.after = a;
		//若后继节点a不为空,则将前驱指针指向前驱节点
        if (a != null)
            a.before = b;
        /*
         * 这里存疑,父条件分支已经确保节点 e 不会是尾节点,
         * 那么 e.after 必然不会为 null,不知道 else 分支有什么作用
         */
        else
            last = b;
		//如果last指针为null,则说明该链表只要一个节点p,则将头结点指向p
        if (last == null)
            head = p;
        else {
            // 将 p 接在链表的最后
            p.before = last;
            last.after = p;
        }
        //尾指针指向p
        tail = p;
        ++modCount;
    }
}

上面就是get方法的实现代码,并不是很复杂。现在我们以下面几张图片为例子演示一下访问key为19的节点,加深下理解。 当我们访问LinkedHashMap中key为19的元素时,双向链表会先将19的后继节点指向null。

然后查看19是否有前驱节点,发现19的前驱节点为10,故而让10的后继指针指向19的后继节点53 如果19的后继节点不为空,则让其的前驱指针也指向19的前驱节点10,即53指向10。 此时回到19节点,链表中有存在尾节点,故而19节点会将自己的前驱指针指向尾节点,尾节点则将其后继指针指向19节点,而后tail指针指向19节点,至此访问19节点链路结束。

remove方法

LinkedHashMap本身并没有覆写父类的remove方法,而是直接使用了父类的实现。remove方法并不会修复双向链表,而是通过afterNodeRemoval方法移除双向链表中的节点。linkedHashMap重写了该方法。相关源码如下,我们可以看到从HashMap中继承来的remove方法内部调用了removeNode方法将节点删除后,调用了afterNodeRemoval方法。

java 复制代码
// HashMap 中实现
public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

// HashMap 中实现
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) {...}
            else {
                // 遍历单链表,寻找要删除的节点,并赋值给 node 变量
                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) {...}
            // 将要删除的节点从单链表中移除
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);    // 调用删除回调方法进行后续操作
            return node;
        }
    }
    return null;
}

我们查看afterNodeRemoval源码,可以看出它整主要逻辑就是让当前节点的前驱和后继节点与当前节点断开,让其等待GC回收,具体步骤如下:

  1. 获取当前节点p及其前驱节点b、后继节点a。
  2. 将节点p的前驱和后继节点设置为null。
  3. 判断前驱节点是否为空,若为空则说明当前节点p为头结点,将头指针指向后继节点a即可;不为空则将前驱节点b指向后继节点a。
  4. 判断后继节点是否为空,若为空则说明当前节点为尾结点,将尾指针指向前驱节点即可;不为空则将后继节点a指向前驱节点b。
java 复制代码
void afterNodeRemoval(Node<K,V> e) { // unlink
	// 获取当前节点p及其前驱节点b、后继节点a
    LinkedHashMap.Entry<K,V> p =
        (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
    // 将 p 节点的前驱后后继引用置空
    p.before = p.after = null;
    // b 为 null,表明 p 是头节点
    if (b == null)
        head = a;
    else
        b.after = a;
    // a 为 null,表明 p 是尾节点
    if (a == null)
        tail = b;
    else
        a.before = b;
}

具体我们可以用以下链表,更加清晰的描述afterNodeRemoval方法是如何操作的,假设我们要将节点19从链表中移除的。

先将节点19的前驱和后指针置为空,确保被删节点与其他节点之间断开连接

判断节点19是否有前驱节点,若有则将其后继指针指向节点19的后继节点,即指向节点21;若无则将头指针指向节点19的的后继节点。

判断节点19是否有后继节点,若有则将其前驱指针指向节点19的前驱节点,即指向节点10;若无则将尾指针指向节点19的的前驱节点,至此完成后节点19再无其他引用指向,等待被GC。

put方法

与删除操作一样,LinkedHashMap 删除操作相关的代码也是直接用父类的实现。但是HashMap中的Put方法插入的是内部类Node的节点,并不具备链表的特性,为了维护双向链表访问的有序性,LikedHashMap在其中处理了两件事:

  1. 重写afterNodeAccess方法,当accessOrder为true时,将被访问节点移动到链表末端
  2. 重写afterNodeInsertion方法,根据removeEldestEntry判断是否移除最近最少被访问的节点 上面所说我们可以从LinkedHashMap源码中看到。
java 复制代码
// HashMap 中实现
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) {...}
    // 通过节点 hash 定位节点所在的桶位置,并检测桶中是否包含节点引用
    if ((p = tab[i = (n - 1) & hash]) == null) {...}
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode) {...}
        else {
            // 遍历链表,并统计链表长度
            for (int binCount = 0; ; ++binCount) {
                // 未在单链表中找到要插入的节点,将新节点接在单链表的后面
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) {...}
                    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) {...}
              //如果当前的key在map中存在,则调用afterNodeAccess
            afterNodeAccess(e);    
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold) {...}
    //调用插入后置方法
    afterNodeInsertion(evict); 
    return null;
}

这里我们着重了解一下afterNodeInsertion方法,我们看一下afterNodeInsertion源码会发现由于removeEldestEntry方法默认一直返回的false而无执行意义,如果要让它有意义就必须重写removeEldestEntry

java 复制代码
void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    // 根据条件判断是否移除最近最少被访问的节点
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

// 移除最近最少被访问条件之一,通过覆盖此方法可实现不同策略的缓存
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}

我们假设重写了removeEldestEntry,当链表的size()>MAX_CAPACITY时,返回true。

java 复制代码
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return this.size() > this.MAX_CAPACITY ;
}

此时假设MAX_CAPACITY为3,然后我们往链表插入一个节点,removeEldestEntry返回true,则会将移除链表首节点 移除首节点的步骤很简单,调用的removeNode方法在上文有详细介绍过,这边简单描述,查看链表首节点是否存在,若存在则断开首节点和后继节点的关系,并让首节点指针指向下一节点,所以head指针指向了19,节点10成为没有任何引用指向的空对象,等待GC回收。 那么通过图解和查看源码,我们可以将afterNodeInsertion做的事情分为以下几个步骤:

  1. 判断evict是否为true,当evict为true时才会需要将最老的键值对移除,但是光evict为true还不行,还得判断链表是否为空,以及removeEldestEntry方法是否返回true,当所有条件都满足true,就需要进行移除操作。
  2. 满足上述条件后,获取链表第一个元素的key。
  3. 然后调用removeNode方法,将节点从bucket中移除,然后调用linkedHashMap本身重写的afterNodeRemoval将元素与双向链表断开等待GC回收,afterNodeRemoval方法我们下文会详细介绍。

基于LinkedHashMap实现LRU缓存

基于上述内容我们了解到可以通过LinkedHashMap封装一个LRU缓存,确保当存储的元素超过容器容量时,将最近最少访问的元素删除。实现代码如下:

java 复制代码
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private static final int MAX_NODE_NUM = 100;

    private final int capacity;

    public LRUCache(int capacity) {
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }


    public V getOne(Object key) {
        return get(key);
    }

    public V save(K key, V value) {
        return put(key, value);
    }

    public Boolean exists(K key) {
        return containsKey(key);
    }

    /**
     * 判断节点数是否超过容器容量,超过时告知LinkedHashMap删除最近最少访问的元素
     * @return 超限返回 true,否则返回 false
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }
}

测试代码如下:

java 复制代码
public class LRUCacheTest {
    public static void main(String[] args) {
        LRUCache<Integer, Integer> cache = new LRUCache<>(3);
        for (int i = 0; i < 5; i++) {
            cache.save(i, RandomUtil.randomInt(0, 5));
        }
        System.out.println("插入5个键值对后的cache内容:" + cache);
        cache.getOne(3);
        System.out.println("访问节点3后的cache内容:" + cache);
         cache.put(5,5);
        System.out.println("插入节点5后的cache内容:" + cache);

    }
}

从输出结果来看,在测试代码中,因为缓存size为3,当我们向缓存中插入5个键值对时,只有后3个元素被保留下来,当我们访问key为3键值对后,该节点被移到链表的最后位置;当我们往链表中插入一个新的键值对时,链表最前面的键值对被删除,而key为3的键值对则向前移动一位。

java 复制代码
插入5个键值对后的cache内容:{2=3, 3=3, 4=2}
访问节点3后的cache内容:{2=3, 4=2, 3=3}
插入节点5后的cache内容:{4=2, 3=3, 5=5}

LinkedHashMap与HashMap遍历性能差异

HashMap由于是按照key的hash值映射到对应的bucket中,无法保证遍历HashMap时的顺序是预期的顺序,而LinkedHashMap在HashMap的基础上维护了一个双向链表来记录数据插入的顺序,因此在迭代遍历生成的迭代器的时候,是按照双向链表的路径进行遍历的。这使得LinkedHashMap在遍历时比HashMap高效许多。 我们可以从两者的迭代器进行分析,先从HashMap的迭代器分析,从源码中可以看到HashMap迭代会调用到nextNode方法,该方法会返回next指向的下一个元素,并且会从next开始遍历bucket,直到所有不为空的bucket都遍历完。

java 复制代码
 final class EntryIterator extends HashIterator
       implements Iterator<Map.Entry<K,V>> {
       public final Map.Entry<K,V> next() { return 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();
	   //如果下一个结点为空 && table不为空,表示当前bucket中所有结点已经遍历完
	   if ((next = (current = e).next) == null && (t = table) != null) {
	   		// 寻找下一个不为空的bucket
	       do {} while (index < t.length && (next = t[index++]) == null);
	   }
	   return e;
}

LinkedHashMap的迭代器则是直接通过当前节点的后继节点获取下一个节点,这段代码非常简洁明了。

java 复制代码
    final class LinkedEntryIterator extends LinkedHashIterator
        implements Iterator<Map.Entry<K,V>> {
        public final Map.Entry<K,V> next() { return nextNode(); }
    }
    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 指针指向当前节点
          current = e;
          //next指向当前节点的后继节点
          next = e.after;
          return e;
    }

我们可以用一段代码测试一下两个容器读取1000w数据的耗时,代码如下:

java 复制代码
  public static void main(String[] args) {
        int count = 1000_0000;
        Map<Integer, Integer> hashMap = new HashMap<>();
        Map<Integer, Integer> linkedHashMap = new LinkedHashMap<>();

        long start = System.currentTimeMillis();
        for (int i = 0; i < count; i++) {
            hashMap.put(RandomUtil.randomInt(1, count), RandomUtil.randomInt(1, count));
        }
        long end = System.currentTimeMillis();
        System.out.println("hashMap put time: " + (end - start));

        start = System.currentTimeMillis();
        for (int i = 0; i < count; i++) {
            linkedHashMap.put(RandomUtil.randomInt(1, count), RandomUtil.randomInt(1, count));
        }
        end = System.currentTimeMillis();
        System.out.println("linkedHashMap put time: " + (end - start));

        start = System.currentTimeMillis();
        for (Integer value : hashMap.values()) {

        }
        end = System.currentTimeMillis();
        System.out.println("hashMap get time: " + (end - start));

        start = System.currentTimeMillis();
        for (Integer value : linkedHashMap.values()) {
        }
        end = System.currentTimeMillis();
        System.out.println("linkedHashMap get time: " + (end - start));
    }

从输出结果看,LinkedHashMap在插入元素时需要维护双向链表,所以效率不如HashMap快,而在获取值时,因为有了双向链表的关系,反而效率比HashMap快。

java 复制代码
hashMap put time: 6210
linkedHashMap put time: 6340
hashMap get time: 196
linkedHashMap get time: 59

参考文献

www.imooc.com/article/229...

www.cnblogs.com/Spground/p/...

相关推荐
a程序小傲3 分钟前
京东Java面试被问:动态规划的状态压缩和优化技巧
java·开发语言·mysql·算法·adb·postgresql·深度优先
仙俊红5 分钟前
spring的IoC(控制反转)面试题
java·后端·spring
阿湯哥6 分钟前
AgentScope Java 集成 Spring AI Alibaba Workflow 完整指南
java·人工智能·spring
小楼v16 分钟前
说说常见的限流算法及如何使用Redisson实现多机限流
java·后端·redisson·限流算法
与遨游于天地29 分钟前
NIO的三个组件解决三个问题
java·后端·nio
czlczl200209251 小时前
Guava Cache 原理与实战
java·后端·spring
yangminlei1 小时前
Spring 事务探秘:核心机制与应用场景解析
java·spring boot
记得开心一点嘛2 小时前
Redis封装类
java·redis
lkbhua莱克瓦242 小时前
进阶-存储过程3-存储函数
java·数据库·sql·mysql·数据库优化·视图
计算机程序设计小李同学2 小时前
基于SSM框架的动画制作及分享网站设计
java·前端·后端·学习·ssm