Bootstrap

Java集合------LinkedHashMap底层原理

Java集合------LinkedHashMap底层原理

前言

在集合中,除了常用的HashMap,还有今天我们要说的LinkedHashMap.为什么会有LinkedHashMap这个集合呢?因为我们在迭代HashMap的时候是无序的,我们希望有一个有序的map来方便我们的使用,这个时候就有了LinkedHashMap.

正文

首先,LinkedHashMap通过维护一个运行于所有条目的双向链表,保证了集合元素迭代的顺序,这个顺序可以是插入顺序或者访问顺序.

LinkedHashMap的特点
  • key和value都允许为空
  • key重复会覆盖,value可以重复
  • 有序的
  • LinkedHashMap是非线程安全的
LinkedHashMap的基本结构
  1. LinkedHashMap可以认为是HashMap+LinkedList,也就是说,它使用HashMap操作数据结构,也用LinkedList维护插入元素的先后顺序.
  2. LinkedHashMap的实现思想就是多态,理解LinkedHashMap能帮助我们加深对多态的理解.

我们先来看看LinkedHashMap的定义:

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

可以看到,LinkedHashMap继承了HashMap,实现了Map接口,我们再看看它自己的方法:

在这里插入图片描述

我们发现LinkedHashMap中并没有操作数据的方法,也就是说,它操作集合,使用的是HashMap的方法,只是在细节上有以下不同.

而它比HashMap多了两个属性:

//链表的头结点
private transient Entry<K,V> header;
//该属性指取得键值对的方式,是个布尔值,false表示插入顺序,true表示访问顺序,也就是访问次数.
private final boolean accessOrder;

LinkedHashMap有五个构造器:

   	//用默认的初始容量和负载因子构建一个LinkedHashMap,取出键值对的方式是插入顺序
    public LinkedHashMap() {
        super();
        accessOrder = false;
    }

    //构造一个指定初始容量的LinkedHashMap,取得键值对的顺序
    public LinkedHashMap(int initialCapacity) {
        super(initialCapacity);
        accessOrder = false;
    }

    //构造一个指定初始容量和负载因子,按照插入顺序的LinkedHashMap
    public LinkedHashMap(int initialCapacity, float loadFactor) {
        super(initialCapacity, loadFactor);
        accessOrder = false;
    }

    //根据给定的初始容量,负载因子和键值对迭代顺序构建一个LinkedHashMap
    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }

    //通过给定的map创建一个LinkedHashMap,负载因子是默认值,迭代方式是插入顺序.
    public LinkedHashMap(Map<? extends K, ? extends V> m) {
        super(m);
        accessOrder = false;
    }

从构造方法可以看出,默认都是采用插入顺序来维持取出键值对的次序.所有的构造方法都是通过父类的构造方法来建造对象的.

LinkedHashMap和HashMap的区别在于他们的基本数据机构上,我们来看一下LinkedHashMap的基本数据结构Entry:

  private static class Entry<K,V> extends HashMap.Entry<K,V> {
        // These fields comprise the doubly linked list used for iteration.
        Entry<K,V> before, after;

        Entry(int hash, K key, V value, HashMap.Entry<K,V> next) {
            super(hash, key, value, next);
        }

LinkedHashMap的Entry类继承了HashMap的Entry,并在此基础上进行了扩展,它拥有以下属性:

K key;
V value;
Entry<K, V> next;
int hash;
Entry<K, V> before;
NEtry<K, V> after;

前面的四个属性,是从HashMap中继承过来的,后面的两个是LinkedHashMap独有的,在这里需要明确next,before,after这三个属性的意思:

next是用于维护HashMap指定table位置上连接的Entry顺序的;before、after是用于维护Entry插入的先后顺序的.

正是因为before、after和header的存在,LinkedHashMap才形成了循环双向链表.

需要注意的是,header节点,是LinkedHashMap的一个属性,它并不保存key-value内容,它是双向链表的入口.

LinkedHashMap的初始化

为了分析LinkedHashMap的底层原理,我们创建一个简单的集合,通过debug来看一下它的初始化过程:

    @Test
    public void test5() {
        Map<String, String> map = new LinkedHashMap<>();
        map.put("111", "111");
        map.put("key2", "value2");
    }

我们通过debug可以看到,当map被new出来以后,调用了HashMap的构造方法:

    public LinkedHashMap() {
        super();
        accessOrder = false;
    }
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        init();
    }

看到上面的代码,是HashMap初始化的步骤,在最后的地方调用了init()方法,但是在HashMap中,init方法是空的,且因为多态的关系,这里最后一步实际上会调用LinkedHashMap的init()方法:

    void init() {
        header = new Entry<>(-1, null, null, null);
        header.before = header.after = header;
    }

看到代码,我们发现,init方法实际上是对LinkedHashMap的header字段进行了初始化,它没有保存任何的数据,且它的before和after都指向自己.

在header中,hash值为-1,其他都为null,也就是说这个header不在数组table中,其实它就是用来指示开源元素、标记结束元素的.header的目的就是为了记录第一个插入的元素是谁,在遍历的时候能够找到第一个元素

此时,LinkedHashMap初始化完成,这个时候,LinkedHashMap内部包含一个长度为16的空数组和一个空的Entry对象,也就是header.

LinkedHashMap保存元素

在上面步骤的基础上,我们完成了LinkedHashMap的初始化工作,接下来我们又往集合中添加了两个元素.

LinkedHashMap并没有重写父类HashMap的put方法,而是重写了父类put方法逻辑中调用的子方法addEntry()createEntry().

    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        //在这里调用的是LinkedHashMap重写后的addEntry方法
        addEntry(hash, key, value, i);
        return null;
    }

在最后一步调用LinkedHashMap的addEntry方法:

    void addEntry(int hash, K key, V value, int bucketIndex) {		
        //调用HashMap的addEntry
        super.addEntry(hash, key, value, bucketIndex);

        // Remove eldest entry if instructed
        Entry<K,V> eldest = header.after;
        if (removeEldestEntry(eldest)) {
            removeEntryForKey(eldest.key);
        }
    }
    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
		//在这里调用LinkedHashMap自己的createEntry方法.
        createEntry(hash, key, value, bucketIndex);
    }

LinkedHashMap自己的createEntry()方法:

    void createEntry(int hash, K key, V value, int bucketIndex) {
        HashMap.Entry<K,V> old = table[bucketIndex];
        Entry<K,V> e = new Entry<>(hash, key, value, old);
        table[bucketIndex] = e;
        e.addBefore(header);
        size++;
    }

createEntry方法覆盖了父类HashMap中的方法.这个方法在这里不会扩展table数组的大小.

该方法首先保留table中bucketIndex处的节点,然后基于HashMap.Entry的构造方法添加一个节点,将当前节点的next引用指向table中bucketIndex处的节点,之后调用addBefore方法修改链表,将e节点添加到header节点之前.

其实以上的操作和HashMap的操作没有什么不同,都是把新添加的节点放在了table[bucketIndex]位置上,差别在于LinkedHashMap还做了addBefore操作,而addBefore方法的目的就是让新的Entry和原链表生成一个双向链表.

假设我们map.put("111", "111");步骤生成的entry的地址是0x00000001,那么用图来表示就是这样的:

在这里插入图片描述

而上图中的内容就是addBefore方法的结果:

        private void addBefore(Entry<K,V> existingEntry) {
            after  = existingEntry;
            before = existingEntry.before;
            before.after = this;
            after.before = this;
        }

上面方法中的existingEntry表示的header节点,那么这段代码可以这样理解:

  1. after=existingEntry,即新增的Entry的after=header地址,即after=0x00000000
  2. before=existingEntry.before,即新增的Entry的before是header的before的地址,header的before此时是0x00000000,因此新增的Entry的before=0x00000000
  3. before.after=this,新增的Entry的before此时为0x00000000即header,header的after=this,即header的after=0x00000001
  4. after.before=this,新增的Entry的after此时为0x00000000即header,header的before=this,即header的before=0x00000001

虽然有点绕,但是详细的想一想,经过这几步以后,header和新增的entry就形成了一个双向循环链表,而后面再添加的元素也是一样的道理

在这里需要注意的是,before和after这两个之前和之后的定义:

  1. 从table的角度看,新的Entry需要插入到对应的bucket(桶)里,当有哈希冲突时,采用头插法(JDK1.8采用尾插法)将新的entry插入到冲突链表的头部.
  2. 从header的角度看,新的entry需要插入到双向链表的尾部,也就是说对于新添加的entry,header的位置代表前,即before,这样子就很好理解了

现在回过头来看,就很好理解了:LinkedHashMap的实现就是HashMap+LinkList,用HashMap来维护数据结构,用LinkList维护数据插入的顺序.

LinkedHashMap保存元素

LinkedHashMap重写了父类HashMap的get方法,我们来看一下它获取元素的时候有何不同:

    public V get(Object key) {
        //调用父类的getEntry方法获取元素
        Entry<K,V> e = (Entry<K,V>)getEntry(key);
        if (e == null)
            return null;
        //该方法用来记录访问顺序.
        e.recordAccess(this);
        return e.value;
    }

可以看到,在get方法中,它是调用了父类的getEntry方法来获取到元素,之后再调用自己的recordAccess()方法.

        void recordAccess(HashMap<K,V> m) {
            //因为在之前是转型为父类对象来获取entry的,所以这里要转回LinkedHashMap,判断获取数据的方式.
            LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
            //当LinkedHashMap按照访问来排序时
            if (lm.accessOrder) {
                lm.modCount++;
                //移除当前节点
                remove();
                //将当前节点插入到头节点前面,即最后面.
                addBefore(lm.header);
            }
        }
//移除节点,并修改引用        
private void remove() {
            before.after = after;
            after.before = before;
        }

综上所述,get方法的逻辑是这样的:

调用父类的getEntry方法获取节点数据以后,再判断当前排序模式accessOrder,如果accessOrder是true,即按照访问顺序排序,那就将当前节点从链表中移除,然后再将当前节点插入到链表的尾部.

利用LinkedHashMap实现LRU算法缓存

因为LinkedHashMap特殊的结构,我们可以用它来实现LRUCache:

public class LRUCache extends LinkedHashMap
{
    public LRUCache(int maxSize)
    {
        super(maxSize, 0.75F, true);
        maxElements = maxSize;
    }

    protected boolean removeEldestEntry(java.util.Map.Entry eldest)
    {
        return size() > maxElements;
    }

    private static final long serialVersionUID = 1L;
    protected int maxElements;
}

LinkedHashMap可以实现LRU缓存的原因有两个:

  1. LinkedHashMap是一个Map,基于K-V,和缓存一致
  2. LinkedHashMap有一个boolean属性可以让用户指定是否实现LRU

所谓LRU:Least Recently Used,最近最少使用,即当缓存了,会优先淘汰那些最近不常访问的数据.即冷数据优先淘汰.

我们来看看LinkedHashMap的一个构造方法:

    //根据给定的初始容量,负载因子和键值对迭代顺序构建一个LinkedHashMap
    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }

在这个构造方法中,有个accessOrder,它不同的值有不同的意义:

  1. false, 所有的Entry按照插入的顺序排列

  2. true, 所有的Entry按照访问的顺序排列

    访问的顺序:如果有1 2 3这3个Entry,那么访问了1,就把1移到尾部去,即2 3 1。每次访问都把访问的那个数据移到双向队列的尾部去,那么每次要淘汰数据的时候,双向队列最头的那个数据不就是最不常访问的那个数据了吗?换句话说,双向链表最头的那个数据就是要淘汰的数据。

而这里的访问也包含两种:

  • 根据key拿到value,即get方法;
  • 修改key对应的value,即put方法;

对应前面的代码,即两个方法中的共同点:recordAccess方法,而且这个方法在Entry中,也就是说每次recordAccess操作的都是某一个固定的Entry.

我们来看看recordAccess方法的代码:

        void recordAccess(HashMap<K,V> m) {
            LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
            if (lm.accessOrder) {
                lm.modCount++;
                remove();
                addBefore(lm.header);
         
        private void remove() {
            before.after = after;
            after.before = before;
        }
        private void addBefore(Entry<K,V> existingEntry) {
            after  = existingEntry;
            before = existingEntry.before;
            before.after = this;
            after.before = this;
        }

可以看到,其实recordAccess方法就做了两件事:

  1. 把待移动的Entry的前后entry相连
  2. 把待移动的Entry移动到链表尾部

做这两步的前提是accessOrder为true

总结

本文结合源码对LinkedHashMap的底层进行了剖析,其实理解了HashMap以后,LinkedHashMap理解起来就很容易了,关键点在于header几点和其他节点的before、after的转换形成双向循环链表.

;