LinkedHashMap 源码阅读

LinkedHashMap 源码阅读

LinkedHashMap 简介

LinkedHashMap 是 Java 提供的一个集合类,它继承自 HashMap,并且在 HashMap 的基础上维护了一条双向链表,使得其具备以下特性:

  1. 支持遍历时按照插入顺序来遍历
  2. 支持按照访问顺序遍历,可以实现 LRU 缓存
  3. 由于 LinkedHashMap 内部使用双向链表来维护每个节点,所以遍历效率和元素个数成正比,相比于和容量成正比的 HashMap 来说效率更高。

LinkedHashMapHashMap 基础上在各个节点之间维护一条双向链表,使得原本散列在不同 bucket 上的节点、链表、红黑树关联起来。

LinkedHashMap 使用示例

插入顺序遍历

如下面所示,我们可以往 LinkedHashMap 里插入元素然后顺序遍历

java 复制代码
HashMap < String, String > map = new LinkedHashMap < > ();
map.put("a", "2");
map.put("g", "3");
map.put("r", "1");
map.put("e", "23");

for (Map.Entry < String, String > entry: map.entrySet()) {
    System.out.println(entry.getKey() + ":" + entry.getValue());
}

输出

java 复制代码
a:2
g:3
r:1
e:23

可以看出,LinkedHashMap 的插入顺序和迭代顺序是一致的,这是 HashMap 所不具备的。

访问顺序遍历

LinkedHashMap 有一个构造参数 accessOrder(boolean类型,默认为 false),要使其遵循插入顺序则为 false,访问顺序则为 true。

java 复制代码
LinkedHashMap<Integer, String> map = new LinkedHashMap<>(16, 0.75f, true);
map.put(1, "one");
map.put(2, "two");
map.put(3, "three");
map.put(4, "four");
map.put(5, "five");
//访问元素2,该元素会被移动至链表末端
map.get(2);
//访问元素3,该元素会被移动至链表末端
map.get(3);
for (Map.Entry<Integer, String> entry : map.entrySet()) {
    System.out.println(entry.getKey() + " : " + entry.getValue());
}

输出

java 复制代码
1 : one
4 : four
5 : five
2 : two
3 : three

可以看出,LinkedHashMap 的访问顺序和迭代顺序是一致的。

实现 LRU 缓存

由于 LinkedHashMap 可以维护内部的访问顺序与迭代顺序一致,则可以实现一个建议的 LUR(Least Recently Used) 缓存,确保当存放的元素超过规定的容量时,可以将最不常访问的元素删除。

实现思路:

  • 继承 LinkedHashMap
  • 构造方法中指定 accessOrder 为 true ,这样在访问元素时就会把该元素移动到链表尾部,链表首元素就是最近最少被访问的元素;
  • 重写 removeEldestEntry 方法,该方法会返回一个 boolean 值,告知 LinkedHashMap 是否需要移除链表首元素(缓存容量有限)。
java 复制代码
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

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

    /**
     * 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素)
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }
}

测试代码如下:

java 复制代码
LRUCache<Integer, String> cache = new LRUCache<>(3);
cache.put(1, "one");
cache.put(2, "two");
cache.put(3, "three");
cache.put(4, "four");
cache.put(5, "five");
for (int i = 1; i <= 5; i++) {
    System.out.println(cache.get(i));
}

输出

java 复制代码
null
null
three
four
five

LinkedHashMap 源码解析

Node 的设计

java 复制代码
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);
        }
    }
java 复制代码
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
	//略

}

可以看到,LinkedHashMapEntry 继承自 HashMapNode,并在此基础上增加了前驱节点和后驱节点。而 TreeNode 直接继承了 LinkedHashMapEntry,拥有前驱节点和后驱节点。

构造方法

LinkedHashMap 是直接调用父类的构造方法来完成初始化。

java 复制代码
public LinkedHashMap() {
    super();
    accessOrder = false;
}

public LinkedHashMap(int initialCapacity) {
    super(initialCapacity);
    accessOrder = false;
}

public LinkedHashMap(int initialCapacity, float loadFactor) {
    super(initialCapacity, loadFactor);
    accessOrder = false;
}

public LinkedHashMap(int initialCapacity,
    float loadFactor,
    boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}

值得注意的是,默认情况下 accseeOrder 是 false,也就是说默认 LinkedHashMap 是保证插入顺序的,要想使其保证访问顺序,需要将第四个参数设为 true。

get 方法

get 方法是 LinkedHashMap 增删改查中唯一重写的方法,accessOrder 为 true 的情况下,每次查询操作后,会把当前严肃移至链表尾。

java 复制代码
public V get(Object key) {
     Node < K, V > e;
     //获取key的键值对,若为空直接返回
     if ((e = getNode(hash(key), key)) == null)
         return null;
     //若accessOrder为true,则调用afterNodeAccess将当前元素移到链表末尾
     if (accessOrder)
         afterNodeAccess(e);
     //返回键值对的值
     return e.value;
 }
  1. 调用父类 HashMapgetNode 获取键值对,若为空则直接返回。
  2. 判断 accessOrder 是否为 true,如果为 true 则会执行第三步
  3. 调用 LinkedHashMap 重写的 afterNodeAccess 方法,将当前节点移至链表末尾。
java 复制代码
void afterNodeAccess(Node < K, V > e) { // move node to last
    LinkedHashMap.Entry < K, V > last;
    //如果accessOrder 且当前节点不未链表尾节点
    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 指向前驱节点,这个 else其实 没有意义,因为最开头if已经确保了p不是尾结点了,自然after不会是null
            last = b;

        //如果last为空,则说明当前链表只有一个节点p,则将head指向p
        if (last == null)
            head = p;
        else {
            //反之让p的前驱指针指向尾节点,再让尾节点的前驱指针指向p
            p.before = last;
            last.after = p;
        }
        //tail指向p,自此将节点p移动到链表末尾
        tail = p;

        ++modCount;
    }
}
  1. 首先判断,只有当 accessOrder 为 true 且当前节点不为链表尾节点我们才需要将节点移至链表尾。
  2. 记录当前节点 p 以及前驱节点 b 和后驱节点 a
  3. 将当前节点 p 的后继节点置为空
  4. 判断前驱节点是否为空,如果为空则说明 p 是首节点,直接将 a 设置为首节点;反之让 b 的后继指向 a
  5. 再次判断后继是否为空,如果为空则说明 p 是尾节点,直接让 last 指向前驱节点 b;反之将前驱节点和后驱节点联系起来
  6. 上面的操作已经保证了将 p 从链表中拿出来,现在将 p 追加到链表末端,如果链表中只有 p 一个节点,直接让头节点指向 p,反之让 p 跟尾节点联系起来
  7. 此时已经成功修改了链表,将 tail 指向 p 即可

remove 方法后置操作------afterNodeRemoval

LinkedHashMap 并没有对 remove 方法进行重写,而是直接继承 HashMapremove 方法,为了保证键值对移除后双向链表中的节点也会同步被移除,LinkedHashMap 重写了 HashMap 的空实现方法 afterNodeRemoval

java 复制代码
final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        //略
            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;
                //HashMap的removeNode完成元素移除后会调用afterNodeRemoval进行移除后置操作
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }
//空实现
void afterNodeRemoval(Node<K,V> p) { }

我们可以看到从 HashMap 中继承来的 remove 方法会调用 removeNode将被删除节点从 bucket 中移除,之后会调用一个空方法 afterNodeRemoval,而 LinkedHashMap 正是重写了这个方法。

java 复制代码
void afterNodeRemoval(Node<K,V> e) { // unlink

		//获取当前节点p、以及e的前驱节点b和后继节点a
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
		//将p的前驱和后继指针都设置为null,使其和前驱、后继节点断开联系
        p.before = p.after = null;

		//如果前驱节点为空,则说明当前节点p是链表首节点,让head指针指向后继节点a即可
        if (b == null)
            head = a;
        else
        //如果前驱节点b不为空,则让b直接指向后继节点a
            b.after = a;

		//如果后继节点为空,则说明当前节点p在链表末端,所以直接让tail指针指向前驱节点a即可
        if (a == null)
            tail = b;
        else
        //反之后继节点的前驱指针直接指向前驱节点
            a.before = b;
    }

我们可以看到,大致就是进行一系列的非空判断然后将前后节点指向该节点的指针断开,让被删除节点置空等待 GC 回收:

  1. 获取当前节点 p 、前驱节点 b 和后驱节点 a
  2. 让当前节点 p 与其前驱和后驱节点断开联系
  3. 判断前驱节点是否为空,如果为空就让 head 指向后继节点;反之让前驱节点指向后继节点
  4. 判断后继节点是否为空,如果为空则将 tail 指向前驱节点;反之让后继节点也指向前驱节点

put 方法

同样地,LinkedHashMap 没有直接重写插入方法,而是继承自 HashMap。而为了维护双向链表访问的有序性,它做了这两件事:

  1. 重写 afterNodeAccess,如果当前的 key 已经存在于 map 中,则在修改节点之后将该节点置于链表尾
  2. 重写了 HashMapafterInseration 方法,当 removeEldestEntry 返回 true 时,会将链表首节点移除
java 复制代码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        	//略
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                 //如果当前的key在map中存在,则调用afterNodeAccess
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
         //调用插入后置方法,该方法被LinkedHashMap重写
        afterNodeInsertion(evict);
        return null;
    }

假设我们重写了 afterNodeInseration 方法,当链表长度 size 超过最大容量 capacity 时就会返回 true。

java 复制代码
/**
 * 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素)
 */
protected boolean removeEldestEntry(Map.Entry < K, V > eldest) {
    return size() > capacity;
}
java 复制代码
void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;
        //如果evict为true且队首元素不为空以及removeEldestEntry返回true,则说明我们需要最老的元素(即在链表首部的元素)移除。
        if (evict && (first = head) != null && removeEldestEntry(first)) {
        	//获取链表首部的键值对的key
            K key = first.key;
            //调用removeNode将元素从HashMap的bucket中移除,并和LinkedHashMap的双向链表断开,等待gc回收
            removeNode(hash(key), key, null, false, true);
        }
    }

我们可以看出,afterNodeInseration 做了如下操作:

  1. 判断 eldest 是否为 true,只有为 true 才说明可能需要将最年长的节点删除,而具体是否要删除还需要判断链表是否为空 (first = head) != null ,以及 removeEldestEntry(first) 是否为 true,只有都返回 true 才说明当前链表不为空且元素数量已经超过了最大容量,可以放心移除最老元素
  2. 获取第一个元素的 key
  3. 调用 HashMapremoveNode 方法,该方法我们上文提到过,它会将节点从 HashMapbucket 中移除,并且 LinkedHashMap 还重写了 removeNode 中的 afterNodeRemoval 方法,所以这一步将通过调用 removeNode 将元素从 HashMap 的 bucket 中移除,并和 LinkedHashMap 的双向链表断开,等待 gc 回收。

LinkedHashMap 和 HashMap 遍历性能比较

LinkedHashMap 维护了一个双向链表来记录数据插入的顺序,因此在迭代遍历生成的迭代器的时候,是按照双向链表的路径进行遍历的。这一点相比于 HashMap 那种遍历整个 bucket 的方式来说,高效需多。

这一点我们可以从两者的迭代器中得以印证,先来看看 HashMap 的迭代器,可以看到 HashMap 迭代键值对时会用到一个 nextNode 方法,该方法会返回 next 指向的下一个元素,并会从 next 开始遍历 bucket 找到下一个 bucket 中不为空的元素 Node

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

 //获取下一个Node
 final Node < K, V > nextNode() {
     Node < K, V > [] t;
     //获取下一个元素next
     Node < K, V > e = next;
     if (modCount != expectedModCount)
         throw new ConcurrentModificationException();
     if (e == null)
         throw new NoSuchElementException();
     //将next指向bucket中下一个不为空的Node
     if ((next = (current = e).next) == null && (t = table) != null) {
         do {} while (index < t.length && (next = t[index++]) == null);
     }
     return e;
 }

相比之下 LinkedHashMap 的迭代器则是直接使用通过 after 指针快速定位到当前节点的后继节点,简洁高效需多。

java 复制代码
 final class LinkedEntryIterator extends LinkedHashIterator
 implements Iterator < Map.Entry < K, V >> {
     public final Map.Entry < K,
     V > next() {
         return nextNode();
     }
 }
 //获取下一个Node
 final LinkedHashMap.Entry < K, V > nextNode() {
     //获取下一个节点next
     LinkedHashMap.Entry < K, V > e = next;
     if (modCount != expectedModCount)
         throw new ConcurrentModificationException();
     if (e == null)
         throw new NoSuchElementException();
     //current 指针指向当前节点
     current = e;
     //next直接当前节点的after指针快速定位到下一个节点
     next = e.after;
     return e;
 }
相关推荐
WhereIsMyChair2 小时前
BatchNorm、LayerNorm和RMSNorm的区别
人工智能·语言模型
噜~噜~噜~2 小时前
D-CBRS(Diverse Class-Balancing Reservoir Sampling )的个人理解
人工智能·深度学习·持续学习·cbrs·d-cbrs
PhDTool2 小时前
计算机化系统验证(CSV)的前世今生
数据库·安全·全文检索
sheji34162 小时前
【开题答辩全过程】以 山林湖泊生态文明建设管控系统为例,包含答辩的问题和答案
java·spring boot
Yeats_Liao2 小时前
MindSpore开发之路(十四):简化训练循环:高阶API `mindspore.Model` 的妙用
人工智能·python·深度学习
欣欣讲AI2 小时前
SpeedAI也有属于自己的Nanobanana大模型生成PPT科研智能体啦
人工智能
沐知全栈开发2 小时前
Python3 日期和时间处理详解
开发语言
A13247053122 小时前
防火墙配置入门:保护你的服务器
linux·运维·服务器·网络
幽络源小助理2 小时前
SpringBoot兼职发布平台源码 | JavaWeb项目免费下载 – 幽络源
java·spring boot·后端