手撕LinkHashMap

LinkedHashMap 继承自 HashMap ,在了解了 HashMap 之后,再来看看 LinkedHashMap 是如何维护它的顺序的。

初始化

LinkedHashMap 的构造函数比 HashMap 的构造函数多维护了一个成员变量 accessOrder 。该成员变量如果为 true ,则按访问顺序,否则按插入顺序(默认为 false)。

看下面这个例子:

        Map<String, String> insertOrderMap = new LinkedHashMap<>();
        for (int i = 0; i < 5; i++){
            insertOrderMap.put(String.valueOf(i), String.valueOf(i));
        }
        assert insertOrderMap.keySet().toString().equals("[0, 1, 2, 3, 4]");

        Map<String, String> accessOrderMap = new LinkedHashMap<>(16, 0.75f,true);
        for (int i = 0; i < 5; i++){
            accessOrderMap.put(String.valueOf(i), String.valueOf(i));
        }
        accessOrderMap.get(String.valueOf(3));
        accessOrderMap.get(String.valueOf(1));
        assert accessOrderMap.keySet().toString().equals("[0, 2, 4, 3, 1]");

断言最终都会正确执行!在访问顺序模式下,调用 get 方法,改变了遍历的顺序。我们知道的是 LinkedHashMap 底层采用了双向链表来维护顺序,查看 get 方法实现:

    public V get(Object key) {
        Node<K,V> e;
        if ((e = getNode(hash(key), key)) == null)
            return null;
        if (accessOrder)
            afterNodeAccess(e);
        return e.value;
    }

accessOrder 为 true 时,会重新维护链表。

    void afterNodeAccess(Node<K,V> e) { // 移动节点到最后
        LinkedHashMap.Entry<K,V> last;
        // 标记最后节点为尾节点
        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;
        }
    }

上面的逻辑还是蛮多的,不过也是因为要考虑的情况比较多,理解起来也不算太复杂。


存取操作

将 key 重新插入到 Map 中,插入顺序不受影响。那如果是访问顺序模式呢?

查看 put 源码,最终发现:

			if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                // 更改链表顺序
                afterNodeAccess(e);
                return oldValue;
            }

上面的代码是在 HashMapputVal 方法的一部分。afterNodeAccess 由子类 LinkedHashMap 实现(模板方法模式)。仅仅只有在重新插入已经存在映射的 key 时,才会调整访问顺序模式下的顺序。

可能比较疑惑的是 在插入顺序模式下,也没有看见 LinkedHashMap 维护双向链表啊

其实不然,在 putVal 方法中有发现这样一行代码吧?

tab[i] = newNode(hash, key, value, null);

子类 LinkedHashMap 重写了该方法;哪怕是在树型化时,需要将该节点转换为树节点的方法 replacementTreeNodeLinkedHashMap 也提供了自己的实现。这种方式是真的漂亮!newNode 重写后的方法如下:

    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);
        linkNodeLast(p);
        return p;
    }

    // link at the end of list
    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;
        }
    }

也就是说,LinkedHashMap 在 put 值的时候,其实,一直在维护链表,这里需要特别留意。


删除操作

LinkedHashMap 并没有重写 remove 方法,那么 ,它是如何实现在删除元素后进行双向链表的处理的呢?查看 remove 方法,最终找到 afterNodeRemoval(node) 方法(模板方法模式),LinkedHashMap 实现了该方法 :

    // 将当前节点从双向链表当中剔除
	void afterNodeRemoval(Node<K,V> e) { // unlink
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        p.before = p.after = null;
        // 如果当前节点为头节点,则重新设置头节点
        if (b == null)
            head = a;
        else
            b.after = a;
        // 如果当前节点为尾节点,则重新设置尾节点
        if (a == null)
            tail = b;
        else
            a.before = b;
    }

迭代器

重要的一步来了,在进行迭代器遍历时,如何保证按照一定的顺序(插入或者访问)来遍历呢?

    abstract class LinkedHashIterator {
        LinkedHashMap.Entry<K,V> next;
        LinkedHashMap.Entry<K,V> current;
        int expectedModCount;

        LinkedHashIterator() {
            // 从头节点开始遍历
            next = 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;
        }

        public final void remove() {
            Node<K,V> p = current;
            if (p == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            current = null;
            K key = p.key;
            removeNode(hash(key), key, null, false, false);
            expectedModCount = modCount;
        }
    }

迭代器的代码比较容易理解,需要注意的是针对不同的集合视图(keySetentrySetvalues),访问不同的迭代器,仅仅是 next 方法中获取节点的值(keynodevalue)不同,所以在不同集合视图下的不同迭代器,只要继承 LinkedHashIterator 类,新增 next 方法,实现 Iterator 接口。

	final class LinkedKeyIterator extends LinkedHashIterator
        implements Iterator<K> {
        public final K next() { return nextNode().getKey(); }
    }

    final class LinkedValueIterator extends LinkedHashIterator
        implements Iterator<V> {
        public final V next() { return nextNode().value; }
    }

    final class LinkedEntryIterator extends LinkedHashIterator
        implements Iterator<Map.Entry<K,V>> {
        public final Map.Entry<K,V> next() { return nextNode(); }
    }

除了以上通过迭代器遍历之外,还有一种方法,这是在 1.8 新加的 forEach 方法:

	public void forEach(BiConsumer<? super K, ? super V> action) {
        if (action == null)
            throw new NullPointerException();
        int mc = modCount;
        for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after)
            action.accept(e.key, e.value);
        if (modCount != mc)
            throw new ConcurrentModificationException();
    }

代码实现来看,这种遍历方法仍然是有序的!

在使用构造函数传入的Map 为LinkedHashMap 时, 重新构造的 LinkedHashMap 仍然维持传入的 LinkedHashMap的顺序。


构建 LRU 缓存

这一点是从文档中发现的,百度了一下 LRU 缓存

LRU 是Least Recently Used的缩写,即最近最少使用。

研究了下具体的实现,重载方法 removeEldestEntry(Map.Entry) ,实现移除最老的 Entry 策略即可。具体的原理是因为 HashMap 在实现 putVal 方法时,调用了回调函数 afterNodeInsertion(evict) ,该函数由子类 LinkedHashMap 做了具体实现:

    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);
        }
    }

正如上述代码,我们只要实现 removeEldestEntry 方法,就能够自定义移除最老的 Entry 策略了。

:实现一个 容量为 10 的 LRU 缓存:

	static class LruCache<K,V> extends LinkedHashMap<K,V>{

        private static int CAPACITY = 10;

        public LruCache(){
            super(16, 0.75f, true);
        }

        /**
         * 缓存计数
         */
        private AtomicInteger count = new AtomicInteger(0);

        @Override
        protected boolean removeEldestEntry(Map.Entry eldest) {

            if(count.getAndIncrement() >= CAPACITY ){
                return true;
            }
            return false;
        }
    }

当实际存储的元素数量大于 10 时,将会踢出最老的元素,为了达到 LRU ,需要在访问顺序的模式下构造 LinkedHashMap;这种实现最终会导致 int 类型溢出,可以使用如下这个类来代替:

public class AtomicPositiveInteger extends Number {


    private static final long serialVersionUID = -3038533876489105940L;

    private static final AtomicIntegerFieldUpdater<AtomicPositiveInteger> indexUpdater =
            AtomicIntegerFieldUpdater.newUpdater(AtomicPositiveInteger.class, "index");

    private volatile int index = 0;

    public AtomicPositiveInteger() {
    }


    public final int getAndIncrement() {
        // 溢出时做 与 运算,
        return indexUpdater.getAndIncrement(this) & Integer.MAX_VALUE;
    }

    public final int get() {
        return indexUpdater.get(this) & Integer.MAX_VALUE;
    }

    public final void set(int newValue) {
        if (newValue < 0) {
            throw new IllegalArgumentException("new value " + newValue + " < 0");
        }
        indexUpdater.set(this, newValue);
    }

    @Override
    public byte byteValue() {
        return (byte) get();
    }

    @Override
    public short shortValue() {
        return (short) get();
    }

    @Override
    public int intValue() {
        return get();
    }

    @Override
    public long longValue() {
        return (long) get();
    }

    @Override
    public float floatValue() {
        return (float) get();
    }

    @Override
    public double doubleValue() {
        return (double) get();
    }

    @Override
    public String toString() {
        return Integer.toString(get());
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + get();
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (!(obj instanceof AtomicPositiveInteger)) return false;
        AtomicPositiveInteger other = (AtomicPositiveInteger) obj;
        return intValue() == other.intValue();
    }
}

上面这种方式,能够保证不溢出(重新归为0),但是无法恢复到该有的容量大小,比如 10,如果要做到这步,应该需要同步了。


总结

从实际问题入手分析源码,思路将会清晰很多;如果遇到新的问题,再就新的问题分析,直到分析完所有问题。这种类似递归般的学习方法还是很不错的。

LinkedHashMap 的排序是由双向链表维护的,链表的元素类型为 LinkedHashMap.Entry<K,V>,底层维护了一个 head 和 一个 tail 记录这个双向链表。

LinkedHashMap 的排序分为:

  1. 插入排序:按照插入时的顺序遍历
  2. 访问排序:在不进行任何访问操作前(即未更改双向链表),按照插入的顺序遍历;在进行访问操作,例如 get 或者 put 一个已经存在的值的时候,会调整双向链表,最近访问的会放在链表的末尾。

可以发现,HashMapLinkedHashMap 的设计实现采用了大量模板方法模式,也就是父类在方法实现中调用未实现方法(或者说留给子类拓展的),子类通过实现该方法来改变行为。

具体总结如下:

  • 在插入数据时,LinkedHashMap 重写 newNode 方法,来修改头尾节点,维护双向链表。
  • 在存数据时(put 操作),
    • 当存放已经存在的 key 值时,会通过实现 afterNodeAccess 方法来调整访问顺序模式下的双向链表。
    • HashMap 预留 afterNodeInsertion 回调方法,LinkedHashMap 实现了该方法, 插入数据时,判断是否需要移除最老的元素(双向链表头)。这有点像个惊喜,我觉得 LinkedHashMap 完全可以不做这个。
  • 在删除数据时,LinkedHashMap 实现 afterNodeRemoval 方法,来调整双向链表。

推荐博文


手撕Java类HashMap

手撕HashMap迭代器

手撕HashMap红黑树


手撕系列让我很有食欲啊!!!


我与风来


认认真真学习,做思想的产出者,而不是文字的搬运工
错误之处,还望指出

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值