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;
}
上面的代码是在 HashMap
的 putVal
方法的一部分。afterNodeAccess
由子类 LinkedHashMap
实现(模板方法模式)。仅仅只有在重新插入已经存在映射的 key 时,才会调整访问顺序模式下的顺序。
可能比较疑惑的是 在插入顺序模式下,也没有看见 LinkedHashMap
维护双向链表啊 ?
其实不然,在 putVal
方法中有发现这样一行代码吧?
tab[i] = newNode(hash, key, value, null);
子类 LinkedHashMap
重写了该方法;哪怕是在树型化时,需要将该节点转换为树节点的方法 replacementTreeNode
,LinkedHashMap
也提供了自己的实现。这种方式是真的漂亮!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;
}
}
迭代器的代码比较容易理解,需要注意的是针对不同的集合视图(keySet
,entrySet
和 values
),访问不同的迭代器,仅仅是 next 方法中获取节点的值(key
,node
和 value
)不同,所以在不同集合视图下的不同迭代器,只要继承 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
的排序分为:
- 插入排序:按照插入时的顺序遍历
- 访问排序:在不进行任何访问操作前(即未更改双向链表),按照插入的顺序遍历;在进行访问操作,例如 get 或者 put 一个已经存在的值的时候,会调整双向链表,最近访问的会放在链表的末尾。
可以发现,HashMap
和 LinkedHashMap
的设计实现采用了大量模板方法模式,也就是父类在方法实现中调用未实现方法(或者说留给子类拓展的),子类通过实现该方法来改变行为。
具体总结如下:
- 在插入数据时,
LinkedHashMap
重写newNode
方法,来修改头尾节点,维护双向链表。 - 在存数据时(put 操作),
- 当存放已经存在的 key 值时,会通过实现
afterNodeAccess
方法来调整访问顺序模式下的双向链表。 HashMap
预留afterNodeInsertion
回调方法,LinkedHashMap
实现了该方法, 插入数据时,判断是否需要移除最老的元素(双向链表头)。这有点像个惊喜,我觉得LinkedHashMap
完全可以不做这个。
- 当存放已经存在的 key 值时,会通过实现
- 在删除数据时,
LinkedHashMap
实现afterNodeRemoval
方法,来调整双向链表。
推荐博文
手撕系列让我很有食欲啊!!!
我与风来
认认真真学习,做思想的产出者,而不是文字的搬运工
错误之处,还望指出