Java 容器源码分析之 LinkedHashMap

同 HashMap 一样,LinkedHashMap 也是对 Map 接口的一种基于链表和哈希表的实现。实际上, LinkedHashMap 是 HashMap 的子类,其扩展了 HashMap 增加了双向链表的实现。相较于 HashMap 的迭代器中混乱的访问顺序,LinkedHashMap 可以提供可以预测的迭代访问,即按照插入序 (insertion-order) 或访问序 (access-order) 来对哈希表中的元素进行迭代。

1
2
3
public class LinkedHashMap<K,V>
extends HashMap<K,V>
implements Map<K,V>

从类声明中可以看到,LinkedHashMap 确实是继承了 HashMap,因而 HashMap 中的一些基本操作,如哈希计算、扩容、查找等,在 LinkedHashMap 中都和父类 HashMap 是一致的。

但是,和 HashMap 有所区别的是,LinkedHashMap 支持按插入序 (insertion-order) 或访问序 (access-order) 来访问其中的元素。所谓插入顺序,就是 Entry 被添加到 Map 中的顺序,更新一个 Key 关联的 Value 并不会对插入顺序造成影响;而访问顺序则是对所有 Entry 按照最近访问 (least-recently) 到最远访问 (most-recently) 进行排序,读写都会影响到访问顺序,但是对迭代器 (entrySet(), keySet(), values()) 的访问不会影响到访问顺序。访问序的特性使得可以很容易通过 LinkedHashMap 来实现一个 LRU(least-recently-used) Cache,后面会给出一个简单的例子。

之所以 LinkedHashMap 能够支持插入序或访问序的遍历,是因为 LinkedHashMap 在 HashMap 的基础上增加了双向链表的实现,下面会结合 JDK 8 的源码进行简要的分析。

底层结构

LinkedHashMap 是 HashMap 的子类,因而 HashMap 中的成员在 LinkedHashMap 中也存在,如底层的 table 数组等,这里就不再说明了。我们重点关注一下 LinkedHashMap 中节点发生的变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* HashMap.Node subclass for normal LinkedHashMap entries.
*/

//LinkedHashMap.Entey 继承自 HashMap.Node,
//而 HashMap.TreeNode 又继承了 LinkedHashMap.Entey
static class Entry<K,V> extends HashMap.Node<K,V> {
//在父类的基础上增加了before 和 after
//父类中存在 next
//双向链表的连接通过before 和 after,哈希表中所有的元素可看作一个双向链表
//桶内单向链表的连接通过 next
Entry<K,V> before, after;
//构造方法
Entry(int hash, K key, V value, Node<K,V> next) {
//父类构造方法
super(hash, key, value, next);
}
}

private static final long serialVersionUID = 3801124242820219131L;

/**
* The head (eldest) of the doubly linked list.
*/

//head成员为双向链表的头
transient LinkedHashMap.Entry<K,V> head;

/**
* The tail (youngest) of the doubly linked list.
*/

//tail成员为双向链表的尾
transient LinkedHashMap.Entry<K,V> tail;

/**
* The iteration ordering method for this linked hash map: <tt>true</tt>
* for access-order, <tt>false</tt> for insertion-order.
*
* @serial
*/

//迭代顺序, true 使用最近被访问的顺序, false为插入顺序
//the order in which its entries were last accessed, from least-recently accessed to most-recently (access-order), well-suited to building LRU caches
final boolean accessOrder;

为了实现双向链表,LinkedHashMap 的节点在父类的基础上增加了 before/after 引用,并且使用 head 和 tail 分别保存双向链表的头和尾。同时,增加了一个标识来保存 LinkedHashMap 的迭代顺序是插入序还是访问序。

由于父类 HashMap 的节点中存在 next 引用,可以将每个桶中的元素都当作一个单链表看待;LinkedHashMap 的每个桶中当然也保留了这个单链表关系,不过这个关系由父类进行管理,LinkedHashMap 中只会对双向链表的关系进行管理。LinkedHashMap 中所有的元素都被串联在一个双向链表中。

双向链表

为了简化对双向链表的操作,LinkedHashMap 中提供了 linkNodeLast 和 transferLinks 方法,分别如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// link at the end of list
// 将新节点 p 链接到双向链表的末尾
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
if (last == null)
//为空,则为头节点
head = p;
else {
//修改before 和 after的指向
p.before = last;
last.after = p;
}
}

// apply src's links to dst
// 将src的链接应用到dst中,就是用dst替换src在双向链表中的位置
private void transferLinks(LinkedHashMap.Entry<K,V> src,
LinkedHashMap.Entry<K,V> dst)
{

//修改dst的前驱和后继指向
LinkedHashMap.Entry<K,V> b = dst.before = src.before;
LinkedHashMap.Entry<K,V> a = dst.after = src.after;
//将双向链表中原来指向src的链接改为指向dst
if (b == null)
head = dst;
else
b.after = dst;
if (a == null)
tail = dst;
else
a.before = dst;
}

LinkedHashMap 重写了父类新建节点的方法,在新建节点之后调用 linkNodeLast 方法将新添加的节点链接到双向链表的末尾:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//覆盖父类方法
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;
}

//覆盖父类方法
//新建TreeNode
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
TreeNode<K,V> p = new TreeNode<K,V>(hash, key, value, next);
//将新建的节点链接到双向链表的末尾
linkNodeLast(p);
return p;
}

我们知道,HashMap 中单个桶中的元素可能会在单链表和红黑树之间进行转换,LinkedHashMap 中当然也是一样,不过在转换时还要调用 transferLinks 来改变双向链表中的连接关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//覆盖父类方法
// For conversion from TreeNodes to plain nodes
// 将节点由 TreeNode 转换为 普通节点
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
LinkedHashMap.Entry<K,V> q = (LinkedHashMap.Entry<K,V>)p; //TreeNode
//根据TreeNode的信息创建新的普通节点
LinkedHashMap.Entry<K,V> t =
new LinkedHashMap.Entry<K,V>(q.hash, q.key, q.value, next);
//将双向链表中的TreeNode替换为新的普通节点
transferLinks(q, t);
return t;
}

//覆盖父类方法
// For treeifyBin
// 将节点由普通节点转换为TreeNode
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
LinkedHashMap.Entry<K,V> q = (LinkedHashMap.Entry<K,V>)p;
TreeNode<K,V> t = new TreeNode<K,V>(q.hash, q.key, q.value, next);
transferLinks(q, t);
return t;
}

如何维护插入序和访问序?

在 LinkedHashMap 中,所有的 Entry 都被串联在一个双向链表中。从上一节的代码中可以看到,每次在新建一个节点时都会将新建的节点链接到双向链表的末尾。这样从双向链表的尾部向头部遍历就可以保证插入顺序了,头部节点是最早添加的节点,而尾部节点则是最近添加的节点。那么,访问顺序要怎么实现呢?

之前我们在分析 HashMap 的源码的时候,在添加及更新、查找、删除等操作中可以看到 afterNodeAccess、afterNodeInsertion、afterNodeRemoval 等几个方法的调用,不过在 HashMap 中这几个方法中没有任何操作。实际上,这几个方法就是供 LinkedHashMap 的重写的,我们不妨看一下在 HashMap 中这几个方法的声明:

1
2
3
4
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

在 LinkedHashMap 中对这几个方法进行了重写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
//移除节点的回调函数
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;
}

//插入节点的回调函数
//构造函数中调用,evict为false
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
//first是头元素,也是最老的元素
//在插入序中,就是最先插入的元素
//在访问序中,就是最远被访问的元素
//这里removeEldestEntry(first)始终返回true,即不删除最老的元素
//如果是一个容量固定的cache,可调整removeEldestEntry(first)的实现
if (evict && (first = head) != null && removeEldestEntry(first)) {
//不是构造方法中
//头元素不为空
//要删除最老的元素
//在LinkedHashMap的实现中,不会进入这里
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}

//访问节点的回调函数
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
//如果是访问序,且当前节点并不是尾节点
//将该节点置为双向链表的尾部
if (accessOrder && (last = tail) != e) {
//p 当前节点, b 前驱结点, a 后继结点
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null; //设为尾节点,则没有后继
if (b == null)
head = a; //p是头节点,调整后其后继结点变为头节点
else
b.after = a;//p不是头节点,前驱和后继结点相连
if (a != null)
a.before = b;
else
last = b;//应该不会出现这种情况,p是尾节点
if (last == null)
head = p;
else {
//将p置于尾节点之后
p.before = last;
last.after = p;
}
tail = p;//调整tail指向
++modCount;//结构性改变
}
}

在插入节点、删除节点和访问节点后会调用相应的回调函数。可以看到,在 afterNodeAccess 方法中,如果该 LinkedHashMap 是访问序,且当前访问的节点不是尾部节点,则该节点会被置为双链表的尾节点。即,在访问序下,最近访问的节点会是尾节点,头节点则是最远访问的节点。

afterNodeInsertion 中,如果 removeEldestEntry(first) 节点返回 true,则会将头部节点删除。如果想要实现一个固定容量的 Map,可以在继承 LinkedHashMap 后重写 removeEldestEntry 方法。在 LinkedHashMap 中,该方法始终返回 false。

1
2
3
4
5
//返回false
//是否移除最老的Entry
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}

在 HashMap 中,在 putVal 和 removeNode 中都调用了相应的回调函数,而 get 则没有,因而在 LinkedHahsMap 中进行了重写:

1
2
3
4
5
6
7
8
9
10
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;
}

遍历及迭代器

因为 LinkeHashMap 的所有的节点都在一个双向链表中,因而可以通过该双向链表来遍历所有的 Entry。而在 HashMap 中,要遍历所有的 Entry,则要依次遍历所有桶中的单链表。相比较而言,从时间复杂度的角度来看,LinkedHashMap 的复杂度为 O(size()),而 HashMap 则为 O(capacity + size())。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
//因为所有的节点都被串联在双向链表中,迭代器在迭代时可以利用双向链表的链接关系进行
//双向链表的顺序是按照插入序或访问序排列的
//相比于HashMap中的迭代,LinkedHashMap更为高效,O(size())
//HashMapde 迭代,O(capacity + size())
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;
}
}

可以看到,在遍历所有节点时是通过节点的 after 引用进行的。这样,可以双链表的头部遍历到到双链表的尾部,就不用像 HahsMap 那样访问空槽了。

containsValueinternalWriteEntries 中也使用了双向链表进行遍历。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public boolean containsValue(Object value) {
//使用双向链表进行遍历
for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after) {
V v = e.value;
if (v == value || (value != null && value.equals(v)))
return true;
}
return false;
}

//覆盖父类方法
//序列化,Called only from writeObject, to ensure compatible ordering.
void internalWriteEntries(java.io.ObjectOutputStream s) throws IOException {
//调整元素的遍历方式,使用双链表遍历
for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after) {
s.writeObject(e.key);
s.writeObject(e.value);
}
}

使用 LinkedHashMap 实现 LRU Cache

LinkedHashMap 的访问序可以方便地用来实现一个 LRU Cache。在访问序模式下,尾部节点是最近一次被访问的节点 (least-recently),而头部节点则是最远访问 (most-recently) 的节点。因而在决定失效缓存的时候,将头部节点移除即可。

但是,由于链表是无界的,但缓存往往是资源受限的,如何确定何时移除最远访问的缓存呢?前面分析过,在 afterNodeInsertion 中,会调用 removeEldestEntry 来决定是否将最老的节点移除,因而我们可以使用 LinkedHashMap 的子类,并重写 removeEldestEntry 方法,当 Enrty 的数量超过缓存的容量是返回 true 即可。

下面给出基于 LinkedHashMap 实现的 LRU Cache 的代码:

public class CacheImpl<K,V> {
    private Map<K, V> cache;
    private int capacity;

    public enum POLICY {
        LRU, FIFO
    }

    public CacheImpl(int cap, POLICY policy) {
        this.capacity = cap;
        cache = new LinkedHashMap<K, V>(cap, 0.75f, policy.equals(POLICY.LRU)){
            //超出容量就删除最老的值
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > capacity;
            }
        };
    }

    public V get(K key) {
        if (cache.containsKey(key)) {
            return cache.get(key);
        }
        return null;
    }

    public void set(K key, V val) {
        cache.put(key, val);
    }

    public void printKV() {
        System.out.println("key value in cache");
        for (Map.Entry<K,V> entry : cache.entrySet()) {
            System.out.println(entry.getKey() + ":" + entry.getValue());
        }
    }

    public static void main(String[] args) {
        CacheImpl<Integer, String> cache = new CacheImpl(5, POLICY.LRU);

        cache.set(1, "first");
        cache.set(2, "second");
        cache.set(3, "third");
        cache.set(4, "fourth");
        cache.set(5, "fifth");
        cache.printKV();

        cache.get(1);
        cache.get(2);
        cache.printKV();

        cache.set(6, "sixth");
        cache.printKV();
    }
}

小结

本文对 JDK 8 中的 LinkedHashMap 的源码及实现进行了简单的分析。LinkedHashMap 继承自 HashMap,并在其基本结构上增加了双向链表的实现,因而 LinkedHashMap 在内存占用上要比 HashMap 高出许多。LinkedHashMap 仍然沿用了 HashMap 中基于桶数组、桶内单链表和红黑树结构的哈希表,在哈希计算、定位、扩容等方面都和 HashMAp 是一致的。LinkedHashMap 同样支持为 null 的键和值。

由于增加了双向链表将所有的 Entry 串在一起,LinkedHashMap 的一个重要的特点就是支持按照插入顺序或访问顺序来遍历所有的 Entry,这一点和 HashMap 的乱序遍历很不相同。在一些对顺序有要求的场合,就需要使用 LinkedHashMap 来替代 HashMap。

由于双向链表的缘故,在遍历时可以直接在双向链表上进行,因而遍历时间复杂度和容量无关,只和当前 Entry 数量有关。这点相比于 HashMap 要更加高效一些。

-EOF-