Java集合------LinkedHashMap底层原理
前言
在集合中,除了常用的HashMap,还有今天我们要说的LinkedHashMap.为什么会有LinkedHashMap这个集合呢?因为我们在迭代HashMap的时候是无序的,我们希望有一个有序的map来方便我们的使用,这个时候就有了LinkedHashMap.
正文
首先,LinkedHashMap通过维护一个运行于所有条目的双向链表,保证了集合元素迭代的顺序,这个顺序可以是插入顺序或者访问顺序.
LinkedHashMap的特点
- key和value都允许为空
- key重复会覆盖,value可以重复
- 有序的
- LinkedHashMap是非线程安全的
LinkedHashMap的基本结构
- LinkedHashMap可以认为是HashMap+LinkedList,也就是说,它使用HashMap操作数据结构,也用LinkedList维护插入元素的先后顺序.
- 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节点,那么这段代码可以这样理解:
- after=existingEntry,即新增的Entry的after=header地址,即after=0x00000000
- before=existingEntry.before,即新增的Entry的before是header的before的地址,header的before此时是0x00000000,因此新增的Entry的before=0x00000000
- before.after=this,新增的Entry的before此时为0x00000000即header,header的after=this,即header的after=0x00000001
- after.before=this,新增的Entry的after此时为0x00000000即header,header的before=this,即header的before=0x00000001
虽然有点绕,但是详细的想一想,经过这几步以后,header和新增的entry就形成了一个双向循环链表,而后面再添加的元素也是一样的道理
在这里需要注意的是,before和after这两个之前和之后的定义:
- 从table的角度看,新的Entry需要插入到对应的bucket(桶)里,当有哈希冲突时,采用头插法(JDK1.8采用尾插法)将新的entry插入到冲突链表的头部.
- 从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缓存的原因有两个:
- LinkedHashMap是一个Map,基于K-V,和缓存一致
- LinkedHashMap有一个boolean属性可以让用户指定是否实现LRU
所谓LRU:Least Recently Used,最近最少使用,即当缓存了,会优先淘汰那些最近不常访问的数据.即冷数据优先淘汰.
我们来看看LinkedHashMap的一个构造方法:
//根据给定的初始容量,负载因子和键值对迭代顺序构建一个LinkedHashMap
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
在这个构造方法中,有个accessOrder
,它不同的值有不同的意义:
-
false, 所有的Entry按照插入的顺序排列
-
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方法就做了两件事:
- 把待移动的Entry的前后entry相连
- 把待移动的Entry移动到链表尾部
做这两步的前提是accessOrder为true
总结
本文结合源码对LinkedHashMap的底层进行了剖析,其实理解了HashMap以后,LinkedHashMap理解起来就很容易了,关键点在于header几点和其他节点的before、after的转换形成双向循环链表.