JAVA集合框架13---LinkedHashMap源码解析

前面我们已经分析过了HashMap的源码, 我们已经知道了HashMap的底层的数据组织就是一个可变长的数组加一个单向链表来实现的,元素储存在hash表中的位置由其hash值和数组大小来确定,如果发生了hash冲突是使用拉链法(也就是单向链表)来解决的。因此HashMap里面储存的元素是无序的,但是有时候我们需要一个有序的集合,JDK提供的集合包中就提供了这样的类来满足我们的要求。它们分别是TreeMap与LinkedHashMap。TreeMap实现了hash表中按键有序,遍历TreeMap可以得到按键(key)的从小到大输出,TreeMap的底层是使用红黑树来实现的,它的源码我们以后在来分析。LinkHashMap是HashMap的子类,它保持了元素按插入或者访问有序。插入有序比较好理解,就是put操作对应的顺序,更改key对应的value值不会改变插入顺序。那么什么是访问顺序呢?所谓访问就是指get/put操作,对一个键执行get/put操作后,其对应的键值对会移到链表末尾。所以最后面的是最近访问的,最开始的是最久没被访问的,这种顺序就是访问顺序。

为了方便下面的解释,我们先给出LinkedHashMap的底层数据组织形式:可变长数组+单向链表+双向链表。其中可变长数组+单向链表的功能和HashMap的功能是一样的,用来实现O(1)时间内元素的put/get操作。双向链表用来记录元素的插入顺序或者访问顺序。

先看下面一个关于访问顺序的例子:

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

public class LinkedHashMapTest {
    public static void main(String[] args) {
        LinkedHashMap<Character, Integer> lm = new LinkedHashMap<>(16,0.75f,true);
        lm.put('a',1);
        lm.put('b',2);
        lm.put('c',3);
        lm.get('b');
        lm.get('a');
        for(Map.Entry<Character, Integer> entry : lm.entrySet()) {
            System.out.println(entry.getKey() + " : " + entry.getValue());
        }
    }
}

对应的输出:

 a是最后访问的,所以a输出在最后,b其次,c输出在最前面。通过这个例子,相信大家就明白什么是访问顺序了。

下面我们来看LinkedHashMap的源码:

先看构造函数:

// LinkedHashMap 继承自HashMap,并且实现了Map接口
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>
{
    private static final long serialVersionUID = 3801124242820219131L;
    // 底层双向链表的头指针
    private transient Entry<K,V> header;
    // 是否维持访问顺序,accessOrder为true表示双向链表维持访问顺序,否则双向链表维持插入顺序
    private final boolean accessOrder;
    // 通过initialCapacity与loadFactor初始化,这两个参数是HashMap需要的,默认维持插入顺序
    public LinkedHashMap(int initialCapacity, float loadFactor) {
        super(initialCapacity, loadFactor);
        accessOrder = false;
    }
    // 通过initialCapacity来初始化,默认维持插入顺序
    public LinkedHashMap(int initialCapacity) {
        super(initialCapacity);
        accessOrder = false;
    }
    // 默认维持插入顺序
    public LinkedHashMap() {
        super();
        accessOrder = false;
    }
    // 默认维持插入顺序
    public LinkedHashMap(Map<? extends K, ? extends V> m) {
        super(m);
        accessOrder = false;
    }
    // 唯一一个可以指定维持访问顺序的构造函数
    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }
    // 这个函数在HashMap中的所有构造中都被调用了,但是在HashMap中该函数为空,这里重写了该函数,因此在new一个LinkedHashMap对象时,会反回来调用这个函数,这里是多态
    @Override
    void init() {
        // 就是初始化了一个头结点 header.before指向双向链表的尾结点,header.after 指向双向链表的头结点,二者相等时,表示双向链表为空。这里就是一个带表头的双向链表。
        header = new Entry<>(-1, null, null, null);
        header.before = header.after = header;
    }
}

LinkedHashMap一共有5个构造函数,其中前四个与HashMap的构造函数一样,他们都默认维持插入顺序,只有一个构造函数可以指定双向链表维持访问顺序。

下面我们来看一下LinkedHashMap里面的Entry是怎么实现的:

// LinkedHashMap中的Entry继承自HashMap中的Entry
private static class Entry<K,V> extends HashMap.Entry<K,V> {
    /*
        HashMap中的Entry主要的成员变量是,key,value,hash,next.
        现在LinkedHashMap中的Entry继承了HashMap中的Entry,并添加了before与after指针
        当然也可以重新建立一个双向链表,里面复制一份key,value,这样做肯定比较浪费空间了。
        不得不说这里的设计非常的巧妙,这样一个Entry里面就含有三个指针了:
        next指针用于使用拉链法来解决hash冲突,before与after指针用来维持插入或者访问顺序,二者互不干扰。且共用了key,value
    */
    // 双向链表的befor与after指针
    Entry<K,V> before, after;

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

    // 在双向链表中删除本节点。双向链表的删除,比较简单
    private void remove() {
        before.after = after;
        after.before = before;
    }

    // 将本节点插入到existingEntry的前面
    private void addBefore(Entry<K,V> existingEntry) {
        after  = existingEntry;
        before = existingEntry.before;
        before.after = this;
        after.before = this;
    }

    // 这个方法和init()方法一样,在HashMap中的Entry中为空。这里子类重写了,当父类HashMap调用时就是多态了。
    // 这个方法,当父类HashMap中调用put且键存在时,HashMap会调用Entry的recordAccess方法,具体可以参见HashMap源码解析那一篇。
    // 这里重写了recordAccess方法,主要的目的就是如果双向链表维持的是访问顺序的话,recordAccess将该键移到双向链表的末尾,如果是插入顺序这里什么也不做。
    void recordAccess(HashMap<K,V> m) {
        LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
        if (lm.accessOrder) {
            lm.modCount++;
            remove();  // 先删除
            addBefore(lm.header);  // 然后添加到链表的尾部
        }
    }
    // 和recordAccess一样,在父类HashMap中的Entry中recordRemoval也为空,这里重写了这个方法。
    // 这个方法当有键被删除时,HashMap会调用Entry的recordRemoval方法,这个方法的作用就是在双向链表中也删除这个键。
    void recordRemoval(HashMap<K,V> m) {
        remove();
    }
}

总结一下LinkedHashMap中Entry的实现方式:LinkedHashMap中的Entry继承自HashMap中的Entry,HashMap中的Entry就是一个单向链表,里面保存了键值对,LinkedHashMap在此基础上增加了后继指针after与前驱指针before,这个双向链表用于维持插入顺序或者访问顺序,这样设计的目的就是为了共用key与value,减少空间的使用。另外,LinkedHashMap还重写了recordAccess与recordRemoval方法,这两个方法在HashMap中的Entry中也有出现,只不过当时这两个方法是空的,因此实际运行时会多态的调用子类方法。recordAccess方法的作用就是当HashMap中执行put操作且值存在时,并且LinkedHashMap维持的是访问顺序的话,会见该节点移到链表的尾部,recordRemoval方法用于在HashMap删除结点时调用,因此在双向链表中该节点也被删除 。

如果维持的访问顺序的话,当get和put一个新值时,该节点也应该被移到链表尾部,下面我们再来看看get与put一个新值时在LinkedHashMap中是怎么实现的:

// LinkedHashMap中的get操作
public V get(Object key) {
    Entry<K,V> e = (Entry<K,V>)getEntry(key);  // 调用父类HashMap的方法
    if (e == null)
        return null;
    e.recordAccess(this);  // 调用子类重写的recordAccess方法(如果是维持访问顺序的话,就将该节点移到双向链表尾部)
    return e.value;
}
// 向LinkedHashMap中添加一个新的键值对时的操作
void addEntry(int hash, K key, V value, int bucketIndex) {
    super.addEntry(hash, key, value, bucketIndex);  // 调用父类HashMap的addEntry方法,这个方法主要就是用来判断是否需要扩容的,实际将节点增加到HashMap中是createEntry函数

    // header.after 是双向链表的首节点,如果双向链表维持的是访问顺序,那么该节点就是最久没有被访问的节点了。
    Entry<K,V> eldest = header.after;
    if (removeEldestEntry(eldest)) {  // 判断是否删除最久没被访问的结点,默认返回一直未false,也就是说一直都不会删除最久没被访问的结点。
        removeEntryForKey(eldest.key);  // 调用父类的删除函数,根据key删除结点,该函数中也会调用recordRemoval函数,因此在双向链表中该节点也会被删除。
    }
}
// 默认的removeEldestEntry实现,一直返回false,该方法为protected,子类可以重写来实现自己的逻辑,比如LRU缓存。
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}
// 重写了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++;
}

由于LInkedHashMap内里面有双向链表来组织数据,因此遍历LinkedHashMap可以使用双向链表来实现,这笔HashMap遍历整个hash表要高效,因此LinkedHashMap重写了HashMap中几个需要遍历的函数使得其在遍历整个hash表上比HashMap更加的高效,下面我们就来看一下这几个实现:

// 在rehash时被调用,这里使用双向链表来遍历整个hash表
@Override
void transfer(HashMap.Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e = header.after; e != header; e = e.after) {
        if (rehash)
            e.hash = (e.key == null) ? 0 : hash(e.key);
        int index = indexFor(e.hash, newCapacity);
        e.next = newTable[index];
        newTable[index] = e;
    }
}
// 使用双向链表来遍历这个hash表
public boolean containsValue(Object value) {
    // Overridden to take advantage of faster iterator
    if (value==null) {
        for (Entry e = header.after; e != header; e = e.after)
            if (e.value==null)
                return true;
    } else {
        for (Entry e = header.after; e != header; e = e.after)
            if (value.equals(e.value))
                return true;
    }
    return false;
}

还有一个重载的函数:clear

public void clear() {
    super.clear();
    header.before = header.after = header;  // 清空双向链表
}

另外,LinkedHashMap中还重写了HashMap中的迭代器方法,也是基于双向链表来实现的,代码也比较简单,这里就不在赘述了。

自此LinkedHashMap的源码就分析完了,我们发现LinkedHashMap的源码并不多,实现也不是很难。其在HashMap的基础上增加了一个双向链表用于维持插入顺序或者访问顺序。另外这里的双向链表的设计也很精髓,与hash表中的结点共用了key,value,从而节约了内存。在维持访问顺序时,每次get和put操作,都会将该节点移到双向链表的尾部。所以双向链表的尾部是最近访问的元素,首部是最久没有被访问的元素。还有一点需要注意的就是,虽然双向链表与hash表共用了key,value,但是它们两者之间的操作互不影响。

猜你喜欢

转载自blog.csdn.net/qq_22158743/article/details/88138091