阿里面试官:LinkedHashMap是怎么保证元素有序的?

开发 前端
这其实跟HashMap的底层设计有关,HashMap并不是像ArrayList那样,按照元素的插入顺序存储。而是先计算key的哈希值,再用哈希值对数组长度求余,算出数组下标,存储到下标所在的位置,如果该位置上存在链表或者红黑树,再把这个元素插入到链表或者红黑树上面。

阿里的上下班时间是1095,这么忙也不能耽误更新《解读Java源码专栏》,在这个系列中,我将手把手带着大家剖析Java核心组件的源码,内容包含集合、线程、线程池、并发、队列等,深入了解其背后的设计思想和实现细节,轻松应对工作面试。

这是解读Java源码系列的第五篇,将跟大家一起学习Java中比较神秘的数据结构 - LinkedHashMap。

引言

新手程序员在使用HashMap的时候,会有个疑问,为什么存到HashMap中的数据不是有序的?

这其实跟HashMap的底层设计有关,HashMap并不是像ArrayList那样,按照元素的插入顺序存储。而是先计算key的哈希值,再用哈希值对数组长度求余,算出数组下标,存储到下标所在的位置,如果该位置上存在链表或者红黑树,再把这个元素插入到链表或者红黑树上面。

这样设计,可以实现快速查询,也就牺牲了存储顺序。因为不同key的哈希值差别很大,所以在数组中存储是无序的。

然而,有时候我们在遍历HashMap的时候,又希望按照元素插入顺序迭代,有没有什么方式能实现这个需求?

有的,就是今天的主角LinkedHashMap,不但保证了HashMap的性能,还实现了按照元素插入顺序或者访问顺序进行迭代。

在这篇文章中,你将学到以下内容:

  1. LinkedHashMap与HashMap区别?
  2. LinkedHashMap特点有哪些?
  3. LinkedHashMap底层实现原理?
  4. 怎么使用``LinkedHashMap实现 LRU 缓存?

简介

LinkedHashMap继承自HashMap,是HashMap的子类,内部额外维护了一个双链表,来保证元素的插入顺序或访问顺序,用空间换时间。 与HashMap相比,LinkedHashMap有三个优点:

  1. 维护了元素插入顺序,支持以元素插入顺序进行迭代。
  2. 维护了元素的访问顺序,支持以元素访问顺序进行迭代。最近访问或者更新的元素,会被移动到链表末尾,类似于LRU(Least Recently Used,最近最少使用)。当面试的时候,手写LRU缓存,需要用到或者参考LinkedHashMap。
  3. 迭代效率更高,迭代LinkedHashMap的时候,不需要遍历整个数组,只需遍历双链表即可,效率更高。

图片图片

类属性

public class LinkedHashMap<K, V> extends HashMap<K, V> implements Map<K, V> {

    /**
     * 头节点
     */
    transient Entry<K, V> head;

    /**
     * 尾节点
     */
    transient Entry<K, V> tail;

    /**
     * 迭代排序方式,true表示按照访问顺序,false表示按照插入顺序
     */
    final boolean accessOrder;

    /**
     * 双链表的节点类
     */
    static class Entry<K, V> extends HashMap.Node<K, V> {
        /**
         * 双链表的前驱节点和后继节点
         */
        Entry<K, V> before, after;

        /**
         * 构造双链表的节点
         *
         * @param hash 哈希值
         * @param key  键
         * @param value 值
         * @param next 后继节点
         */
        Entry(int hash, K key, V value, Node<K, V> next) {
            super(hash, key, value, next);
        }
    }

}

可以看出LinkedHashMap继承自HashMap,在HashMap的单链表Node节点的基础上,增加了前驱节点before、后继节点after、头节点head、尾节点tail,扩展成了双链表节点Entry,并记录了迭代排序方式accessOrder。

初始化

LinkedHashMap常见的初始化方法有四个方法:

  1. 无参初始化
  2. 指定容量大小的初始化
  3. 指定容量大小、负载系数的初始化
  4. 指定容量大小、负载系数、迭代顺序的初始化
/**
 * 无参初始化
 */
Map<Integer, Integer> map1 = new LinkedHashMap<>();

/**
 * 指定容量大小的初始化
 */
Map<Integer, Integer> map2 = new LinkedHashMap<>(16);

/**
 * 指定容量大小、负载系数的初始化
 */
Map<Integer, Integer> map3 = new LinkedHashMap<>(16, 0.75f);

/**
 * 指定容量大小、负载系数、迭代顺序的初始化
 */
Map<Integer, Integer> map4 = new LinkedHashMap<>(16, 0.75f, true);

再看一下构造方法的底层实现:

/**
 * 无参初始化
 */
public LinkedHashMap() {
    super();
    accessOrder = false;
}

/**
 * 指定容量大小的初始化
 */
public LinkedHashMap(int initialCapacity) {
    super(initialCapacity);
    accessOrder = false;
}

/**
 * 指定容量大小、负载系数的初始化
 *
 * @param initialCapacity 初始容量
 * @param loadFactor      负载系数
 */
public LinkedHashMap(int initialCapacity, float loadFactor) {
    super(initialCapacity, loadFactor);
    accessOrder = false;
}

/**
 * 指定容量大小、负载系数、迭代顺序的初始化
 *
 * @param initialCapacity 初始容量
 * @param loadFactor      负载系数
 * @param accessOrder     迭代顺序,true表示按照访问顺序,false表示按照插入顺序
 */
public LinkedHashMap(int initialCapacity,
                     float loadFactor,
                     boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}

LinkedHashMap的构造方法底层都是调用的HashMap的构造方法,迭代顺序accessOrder默认是false,表示按照元素插入顺序迭代,可以在初始化LinkedHashMap的时候指定为 true,表示按照访问顺序迭代。

put源码

LinkedHashMap的put方法完全使用的是HashMap的put方法,并没有重新实现。不过HashMap中定义了一些空方法,留给子类LinkedHashMap去实现。 有以下三个方法:

public class HashMap<K, V> {
    
    /**
     * 在访问节点后执行的操作
     */
    void afterNodeAccess(Node<K, V> p) {
    }

    /**
     * 在插入节点后执行的操作
     */
    void afterNodeInsertion(boolean evict) {
    }

    /**
     * 在删除节点后执行的操作
     */
    void afterNodeRemoval(Node<K, V> p) {
    }
    
}

在HashMap的put源码中就调用前两个方法:

图片图片

看一下afterNodeInsertion()方法的源码,看一下再插入节点后要执行哪些操作? 在插入节点后,只执行了一个操作,就是判断是否删除最旧的节点。removeEldestEntry()方法默认返回false,表示不需要删除节点。我们也可以重写removeEldestEntry()方法,当元素数量超过阈值时,返回true,表示删除最旧的节点。

/**
 * 在插入节点后执行的操作(删除最旧的节点)
 */
void afterNodeInsertion(boolean evict) {
    Entry<K, V> first;
    // 判断是否需要删除当前节点
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        // 调用HashMap的删除节点的方法
        removeNode(hash(key), key, null, false, true);
    }
}

/**
 * 是否删除最旧的节点,默认是false,表示不删除
 */
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
    return false;
}

创建节点

由于afterNodeInsertion()方法并没有把新节点插入到双链表中,所以LinkedHashMap又重写创建节点的newNode()方法,在newNode()方法中把新节点插入到双链表。

public class LinkedHashMap<K, V> extends HashMap<K, V> implements Map<K, V> {

    /**
     * 创建链表节点
     */
    @Override
    Node<K, V> newNode(int hash, K key, V value, Node<K, V> e) {
        // 1. 创建双链表节点
        LinkedHashMap.Entry<K, V> p = new LinkedHashMap.Entry<K, V>(hash, key, value, e);
        // 2. 追加到链表末尾
        linkNodeLast(p);
        return p;
    }

    /**
     * 创建红黑树节点
     */
    @Override
    TreeNode<K, V> newTreeNode(int hash, K key, V value, Node<K, V> next) {
        // 1. 创建红黑树节点
        TreeNode<K, V> p = new TreeNode<K, V>(hash, key, value, next);
        // 2. 追加到链表末尾
        linkNodeLast(p);
        return p;
    }

    /**
     * 追加到链表末尾
     */
    private void linkNodeLast(LinkedHashMap.Entry<K, V> p) {
        LinkedHashMap.Entry<K, V> last = tail;
        tail = p;
        if (last == null) {
            head = p;
        } else {
            p.before = last;
            last.after = p;
        }
    }
}

get源码

再看一下 get 方法源码,LinkedHashMap的 get 方法是直接调用的HashMap的get方法逻辑,在获取到value 后,判断 value 不为空,就执行afterNodeAccess()方法逻辑,把该节点移动到链表末尾,afterNodeAccess()方法逻辑在前面已经讲过。

/**
 * get方法入口
 */
public V get(Object key) {
    Node<K,V> e;
    // 直接调用HashMap的get方法源码
    if ((e = getNode(hash(key), key)) == null) {
        return null;
    }
    // 如果value不为空,并且设置了accessOrder为true(表示迭代顺序为访问顺序),就执行访问节点后的操作
    if (accessOrder) {
        afterNodeAccess(e);
    }
    return e.value;
}

看一下afterNodeAccess()方法的源码实现,看一下在访问节点要做哪些操作?afterNodeAccess()方法的逻辑也很简单,核心逻辑就是把当前节点移动到链表末尾,分为三步:

  1. 断开当前节点与后继节点的连接
  2. 断开当前节点与前驱节点的连接
  3. 把当前节点插入到链表末尾
/**
 * 在访问节点后执行的操作(把节点移动到链表末尾)
 */
void afterNodeAccess(Node<K, V> e) {
    Entry<K, V> last;
    // 当accessOrder为true时,表示按照访问顺序,这时候才需要更新链表
    // 并且判断当前节点不是尾节点
    if (accessOrder && (last = tail) != e) {
        Entry<K, V> p = (Entry<K, V>) e, b = p.before, a = p.after;
        // 1. 断开当前节点与后继节点的连接
        p.after = null;
        if (b == null) {
            head = a;
        } else {
            b.after = a;
        }
        // 2. 断开当前节点与前驱节点的连接
        if (a != null) {
            a.before = b;
        } else {
            last = b;
        }
        // 3. 把当前节点插入到链表末尾
        if (last == null) {
            head = p;
        } else {
            p.before = last;
            last.after = p;
        }
        tail = p;
        ++modCount;
    }
}

remove源码

LinkedHashMap的 remove 方法完全使用的是 HashMap 的 remove 方法,并没有重新实现。不过 HashMap的 remove 中调用了afterNodeRemoval�(),执行删除节点后逻辑,LinkedHashMap重写了该方法的逻辑。

图片图片

/**
 * 在删除节点后执行的操作(从双链表中删除该节点)
 */
void afterNodeRemoval(Node<K, V> e) {
    LinkedHashMap.Entry<K, V> p =
            (LinkedHashMap.Entry<K, V>) e, b = p.before, a = p.after;
    p.before = p.after = null;
    // 1. 断开当前节点与前驱节点的连接
    if (b == null) {
        head = a;
    } else {
        b.after = a;
    }
    // 2. 断开当前节点与后继节点的连接
    if (a == null) {
        tail = b;
    } else {
        a.before = b;
    }
}

总结

现在可以回答文章开头提出的问题:

  1. LinkedHashMap与HashMap区别?

答案:LinkedHashMap继承自HashMap,是HashMap的子类。

  1. LinkedHashMap特点有哪些?

答案:除了保证了与HashMap一样高效的查询和插入性能外,还支持以插入顺序或者访问顺序进行迭代访问。

  1. LinkedHashMap底层实现原理?

答案:LinkedHashMap底层源码都是使用了HashMap的逻辑实现,使用双链表维护元素的顺序,并重写了以下三个方法:

  1. afterNodeAccess(),在访问节点后执行的操作
  2. afterNodeInsertion(),在插入节点后执行的操作。
  3. afterNodeRemoval(),在删除节点后执行的操作。
  4. 怎么使用``LinkedHashMap实现 LRU 缓存?

答案:由于LinkedHashMap内部已经实现按照访问元素的迭代顺序,所以只需复用LinkedHashMap的逻辑,继承LinkedHashMap,重写removeEldestEntry()方法。

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * @author 一灯架构
 * @apiNote 使用LinkedHashMap实现LRU缓存
 */
public class LRUCache<K, V> extends LinkedHashMap<K, V> {

    /**
     * 缓存容量大小
     */
    private final int capacity;

    /**
     * 构造方法
     *
     * @param capacity 缓存容量大小
     */
    public LRUCache(int capacity) {
        // 底层使用LinkedHashMap的构造方法
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }

    /**
     * 当缓存容量达到上限时,移除最久未使用的节点
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }

    public static void main(String[] args) {
        LRUCache<Integer, String> cache = new LRUCache<>(3);
        cache.put(1, "One");
        cache.put(2, "Two");
        cache.put(3, "Three");
        System.out.println(cache); // 输出: {1=One, 2=Two, 3=Three}

        cache.get(2);
        System.out.println(cache); // 输出: {1=One, 3=Three, 2=Two}

        cache.put(4, "Four");
        System.out.println(cache); // 输出: {3=Three, 2=Two, 4=Four}
    }
}

责任编辑:武晓燕 来源: 一灯架构
相关推荐

2023-11-06 17:39:35

JavaArrayList线程

2021-09-27 07:11:18

MySQLACID特性

2021-01-20 07:16:07

幂等性接口token

2022-07-15 08:22:42

对象符串键Symbol

2021-12-21 07:07:43

HashSet元素数量

2021-11-05 10:07:13

Redis哈希表存储

2019-11-21 08:40:44

面试官优化性能

2023-09-01 15:27:31

2024-02-28 10:14:47

Redis数据硬盘

2023-02-08 07:04:20

死锁面试官单元

2021-10-22 08:37:13

消息不丢失rocketmq消息队列

2022-11-25 17:29:27

分布式事务

2023-11-29 08:00:53

JavaTreeMap底层

2021-09-07 10:44:33

Java 注解开发

2020-02-25 16:56:02

面试官有话想说

2021-07-04 15:16:14

索引B+数据库

2021-05-13 07:58:05

HTTPSHTTP安全

2020-12-31 07:34:04

Redis数据宕机

2021-02-19 10:02:57

HTTPSJava安全

2024-02-04 10:08:34

点赞
收藏

51CTO技术栈公众号