容器(十五):LinkedHashMap
标签: Java编程思想
引
大多数情况下,只要不涉及线程安全问题,Map基本都可以使用HashMap,不过HashMap有一个问题,就是迭代HashMap的顺序并不是HashMap放置的顺序,也就是无序。HashMap的这一缺点往往会带来困扰,因为有些场景,我们期待一个有序的Map。
这个时候,LinkedHashMap就闪亮登场了,它虽然增加了时间和空间上的开销,但是通过维护一个运行于所有条目的双向链表,LinkedHashMap保证了元素迭代的顺序。
在理解了HashMap源码的基础上,LinkedHashMap的源码理解起来也不难。
LinkedHashMap简介
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>
LinkedHashMap是HashMap的子类。
与HashMap有着同样的存储结构,但它加入了一个双向链表的头结点,将所有put到LinkedHashmap的节点一一串成了一个双向循环链表,因此它保留了节点插入的顺序,可以使节点的输出顺序与输入顺序相同。
LinkedHashMap实现与HashMap的不同之处在于,一个是有序的,一个是无序的LinkedHashMap维护着一个运行于所有条目的双重链接列表。此链接列表定义了迭代顺序,该迭代顺序可以是插入顺序或者是访问顺序。
要特意说明的是什么是插入访问,什么是顺序访问?
//access为true 表示按照访问顺序进行
//access为false表示按照插入顺序进行
final boolean accessOrder;
- 按照访问的次序来排序的含义:
当调用LinkedHashMap的get(key)或者put(key,value)时,若此时key在map中被包含,那么LinkedHashMap会将key对象的entry放在线性结构的最后。(实现LRU Cache即最近最久未使用) - 按照插入顺序来排序的含义:
调用get(key), 或者put(key, value)并不会对线性结构产生任何的影响。
注意,此实现不是同步的。如果多个线程同时访问链接的哈希映射,而其中至少一个线程从结构上修改了该映射,则它必须保持外部同步。
那么对LinkedHashMap进行总结:
LinkedHashMap是否允许键值对为空?
答:Key和Value都允许空LinkedHashMap是否允许重复数据?
答:Key重复会覆盖、Value允许重复LinkedHashMap是否有序?
答:有序LinkedHashMap是否线程安全?
答:非线程安全
LinkedHashMap的数据结构
从继承关系上来看,LinkedHashMap是继承于HashMap的,因此也是散列表的结构。
从命名关系上来看,“Linked”是它对HashMap功能的进一步增强,LinkedHashMap用双向链表的结构,把所有存储在HashMap中的数据连接起来。
但是有人会说HashMap不是已经有了链表了么,为什么还要再加个双向链表?
这里要注意的是,HashMap的链表结构是存在于桶中的,即当一个桶中有不止一个Node时,就将其以链表的方式进行存储。数组-链表-红黑树构成了数据结构的整体。而双向链表,则是LinkedHashMap的额外引入,与HahMap的桶中的链表只存储而没有顺序不同:双向链表是在整体数据结构的基础上,将LinkedHashMap中所有的数据连接起来,使数据存储有序。
下面在HashMap的基础上进行添加,蓝色线头即为双向链表:
说明:LinkedHashMap会将元素串起来,形成一个双链表结构。可以看到,其结构在HashMap结构上增加了链表结构。数据结构为(数组 + 单链表 + 红黑树 + 双链表),图中的标号是结点插入的顺序。
源码分析
静态内部类
源码的开头是一个静态内部类Entry<K,V>
来表示双向链表中的节点,其实在JDK1.7之前,HashMap中的键值对使用Entry<K,V>
存储的,在JDK1.8中改为了Node<K,V>
,那么在LinkedHashMap中,Entry<K,V>
继承实现了Node<K,V>
。before和after表示前后指针。我们在使用LinkedHashMap有序就是因此产生。
static class Entry<K,V> extends HashMap.Node<K,V> {
//双向链表有前后指针
Entry<K,V> before, after;
//构造函数生成一个Node<K,V>
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
成员变量:
// 版本序列号
private static final long serialVersionUID = 3801124242820219131L;
// Entry<K,V>类型的链表头结点
transient LinkedHashMap.Entry<K,V> head;
// Entry<K,V>链表尾结点
transient LinkedHashMap.Entry<K,V> tail;
// 访问顺序
//access为true表示按照访问顺序进行
//access为false表示按照插入顺序进行
final boolean accessOrder;
构造函数
- LinkedHashMap(int, float)型构造函数:
总是会在构造函数的第一行调用父类HashMap的构造函数,使用super关键字,accessOrder默认为false,即按照插入顺序进行。
什么是访问顺序,什么是插入顺序在后面说明
public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;
}
- LinkedHashMap(int)型构造函数
public LinkedHashMap(int initialCapacity) {
super(initialCapacity);
accessOrder = false;
}
- LinkedHashMap()型构造函数
public LinkedHashMap() {
super();
accessOrder = false;
}
- LinkedHashMap(Map
public LinkedHashMap(Map<? extends K, ? extends V> m) {
super();
accessOrder = false;
putMapEntries(m, false); //调用到父类HashMap的函数
}
- LinkedHashMap(int, float, boolean)型构造函数
可以指定accessOrder的值,从而控制访问顺序,实现LRU。
//当参数accessOrder为true时,即会按照访问顺序排序,最近访问的放在最前,最早访问的放在后面
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
重要方法
将节点链接到尾部
首先确定last节点指向尾节点tail,将尾节点指向要添加的节点p,若last即原尾节点不为空,则将p的前指针指向last,last的后指针指向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;
}
}
替换某个节点
transferLinks()
,给出了两个节点
将链表中的src节点替换为dst节点。先将dst节点与src节点共用前后节点,然后再将前后节点的后前指针指向dst。
// apply src's links to dst
private void transferLinks(LinkedHashMap.Entry<K,V> src,
LinkedHashMap.Entry<K,V> dst) {
LinkedHashMap.Entry<K,V> b = dst.before = src.before;
LinkedHashMap.Entry<K,V> a = dst.after = src.after;
if (b == null)
head = dst;
else
b.after = dst;
if (a == null)
tail = dst;
else
a.before = dst;
}
replacementNode(Node<K,V> p, Node<K,V> next)
给出了一个节点和它的后节点,调用transferLinks
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
LinkedHashMap.Entry<K,V> q = (LinkedHashMap.Entry<K,V>)p;
LinkedHashMap.Entry<K,V> t =
new LinkedHashMap.Entry<K,V>(q.hash, q.key, q.value, next);
transferLinks(q, t);
return t;
}
新建一个节点
有两步:一是新建一个Entry节点,二是将其连接在表的末尾,这个操作维护了插入顺序。
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结点,并接入双向链表尾部。
// 当桶中结点类型为HashMap.TreeNode时,调用此函数
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
// 生成TreeNode结点
TreeNode<K,V> p = new TreeNode<K,V>(hash, key, value, next);
// 将该结点插入双链表末尾
linkNodeLast(p);
return p;
}
将节点添加到链表尾部(put方法)
要说明的是在LinkedHashMap中没有put方法,也就是说LinkedHashMap中使用put()是调用HashMap中的put方法,但是和HashMap中put方法不同的是,LinkedHashMap中实现了HashMap中没有实现的afterNodeAccess(Node<K,V> e)
方法。
afterNodeAccess方法就是如何支持LRU规则的,如果在accessOrder为true的时候,节点调用这个函数,就会把这个节点从链表中取出,然后链接在链表的最后面。put,get等都会调用这个函数来调整顺序
void afterNodeAccess(Node<K,V> e) { // 把当前节点e放到双向链表尾部
LinkedHashMap.Entry<K,V> last; //节点last,用来指向尾节点
//accessOrder就是我们前面说的LRU控制,当它为true,同时e对象不是尾节点(如果访问尾节点就不需要设置,该方法就是把节点放置到尾节点)
if (accessOrder && (last = tail) != e) {
//用a和b分别记录该节点前面和后面的节点
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
//将要添加的后节点设为null
p.after = null;
//如果当前节点的前节点是空,
if (b == null)
//那么头节点就设置为a
head = a;
else
//如果b不为null,那么b的后节点指向a
b.after = a;
//如果a节点不为空
if (a != null)
//a的后节点指向b
a.before = b;
else
//如果a为空,那么b就是尾节点
last = b;
//如果尾节点为空
if (last == null)
//那么p为头节点
head = p;
else {
//否则就把p放到双向链表最尾处
p.before = last;
last.after = p;
}
//设置尾节点为P
tail = p;
//LinkedHashMap对象操作次数+1
++modCount;
}
}
移除头结点
afterNodeInsertion(boolean evict)
这个方法用来移除头结点,是重写了HashMap中的,但是HashMap中并没有去实现它,所以在put的时候就会触发一个机制:给定一个条件evict,控制存储在LinkedHashMap中的最旧的数据何时删除。
必须要evict为true且头节点不为null且 removeEldestEntry(Map.Entry<K,V> eldest)
的返回值必须为true,才能够获取头节点的key然后调用removeNode方法删除
void afterNodeInsertion(boolean evict) { // 可能会移除旧的
LinkedHashMap.Entry<K,V> first; //用来指向头节点
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
removeEldestEntry()的作用是移除最老数据,但是这个方法永远是返回false:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
因此,afterNodeInsertion()由于removeEldestEntry()所返回的false无执行意义。也就意味着如果想要让它有意义必须重写removeEldestEntry()。
使用LinkedHashMap实现一个简单的LRUCache。重写removeEldestEntry(),当超出缓存容器大小时移除最老的头节点(这里不考虑并发问题,如下):
@Override
public boolean removeEldestEntry(Map.Entry<K, V> eldest){
return size() > capacity;
}
删除某一节点
afterNodeRemoval()方法相对简单,就是在删除后处理其对应链表前后关系(刨掉一截)。
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;
}
get方法
是LinkedHashMap重写了HashMap的get方法,get方法中是否调用了afterNodeAccess(e)函数:
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder) //如果启用了LRU规则
afterNodeAccess(e); //那么把该节点移到双向链表最后面
return e.value;
}
还有一个get方法:getOrDefault(Object key, V defaultValue)
如果之前的key对应的节点不存在,那么就返回默认的值defaultValue,如果按访问顺序则调用afterNodeAccess(e),返回其value
public V getOrDefault(Object key, V defaultValue) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return defaultValue;
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
两种演示
LinkedHashMap的核心就是存在存储顺序和可以实现LRU算法,所以下面用两个demo先来证明这两种情况:
存储顺序
放入到LinkedHashMap是有顺序的,会按照你放入的顺序存储:
package char20;
import java.util.LinkedHashMap;
/**
* Created by japson on 8/16/2017.
*/
public class LinkedHashMapTest {
public static void main(String[] args) {
LinkedHashMap<Integer, Integer> map = new LinkedHashMap<Integer, Integer>();
for (int i = 0; i < 10; i++) {//按顺序放入1~9
map.put(i, i);
}
System.out.println("原数据:"+map.toString());
System.out.println("查询key为3的value:"+map.get(3));
System.out.println("添加已经存在的key:<4,100>,并返回value:"+ map.get(map.put(4,100)));
map.put(10, 10);
System.out.println("插入一个原本不存在的<10,10>:"+map.toString());
}
}
输出:
原数据:{0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9}
查询key为3的value:3
添加已经存在的key:<4,100>,并返回value:100
插入一个原本没存在的:{0=0, 1=1, 2=2, 3=3, 4=100, 5=5, 6=6, 7=7, 8=8, 9=9, 10=10}
其实它是符合先进先出的规则的,不管你怎么查询插入已存在的数据,不会对排序造成影响,如果有新插入的数据将会放在最尾部。
启动LRU
启用LRU规则的LinkedHashMap,启动这个规则需要在构造LinkedHashMap的时候,调用三个参数的构造器,第三个参数accessOrder就是用于控制LRU规则的。 演示如下:
package char20;
import java.util.LinkedHashMap;
/**
* Created by japson on 8/16/2017.
*/
public class LinkedHashMapTest {
public static void main(String[] args) {
//调用构造器:public LinkedHashMap(int initialCapacity,float loadFactor,boolean accessOrder)
LinkedHashMap<Integer, Integer> map = new LinkedHashMap<Integer, Integer>(20, 0.75f, true);
for (int i = 0; i < 10; i++) {//按顺序放入1~9
map.put(i, i);
}
System.out.println("原数据按顺序存储:"+map.toString());
int get3 = map.get(3);
System.out.println("查询key为3的键值对,返回其value:"+ get3);
System.out.println("get操作后,根据LRU算法,map调整为:"+map.toString());
int put4 = map.put(4, 100);
System.out.println("添加已经存在的key:<4,100>,并返回value:"+ map.get(put4));
System.out.println("put操作后,根据LRU算法,map调整为:"+ map.toString());
map.put(10, 10);
System.out.println("插入一个原本没存在的:"+map.toString());
}
}
输出:
原数据按顺序存储:{0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9}
查询key为3的键值对,返回其value:3
get操作后,根据LRU算法,map调整为:{0=0, 1=1, 2=2, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9, 3=3}
添加已经存在的key:<4,100>,并返回value:100
put操作后,根据LRU算法,map调整为:{0=0, 1=1, 2=2, 5=5, 6=6, 7=7, 8=8, 9=9, 3=3, 4=100}
插入一个原本没存在的:{0=0, 1=1, 2=2, 5=5, 6=6, 7=7, 8=8, 9=9, 3=3, 4=100, 10=10}
从上面可以看出,每当我get或者put一个已存在的数据,就会把这个数据放到双向链表的尾部,put一个新的数据也会放到双向链表的尾部。
总结
相对于HashMap,LinkedHashMap的源码还是比较容易理解的,因为底层方式使用的是HashMap中的方法。值得注意的就是对于双向链表的维护,使其保存了插入时的顺序,并且通过accessOrder参数来控制是否实现LRU算法,LRU算法涉及到将最近使用(查询,修改,添加)过的数据取出并放在链表尾部,还涉及到当超出了容器大小时,要将最老的头节点删掉。
ps:用心学习,喜欢的话请点赞 (在左侧哦)