热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

LinkedHashMap超详细分析

我先笼统地用语言介绍LinkedHashMap,如果看的不是很明白,不要紧,下面会结合源码进行分析。(1)Map接口的哈希表和链接列表实现,具有可预知的迭代顺序。此实现与HashMap

我先笼统地用语言介绍LinkedHashMap,如果看的不是很明白,不要紧,下面会结合源码进行分析。

(1)Map 接口的哈希表和链接列表实现,具有可预知的迭代顺序。此实现与 HashMap 的不同之处在于,后者维护着一个运行于所有条目的双重链接列表。此链接列表定义了迭代顺序,该迭代顺序通常就是将键插入到映射中的顺序(插入顺序)。

(2)注意,如果在映射中重新插入 键,则插入顺序不受影响。(如果在调用 m.put(k, v) 前 m.containsKey(k) 返回了 true,则调用时会将键 k 重新插入到映射 m 中。)

(3)此实现可以让客户避免未指定的、由 HashMap(及 Hashtable)所提供的通常为杂乱无章的排序工作,同时无需增加与 TreeMap 相关的成本。使用它可以生成一个与原来顺序相同的映射副本,而与原映射的实现无关:

     void foo(Map m) {
Map copy = new LinkedHashMap(m);
...
}

如果模块通过输入得到一个映射,复制这个映射,然后返回由此副本确定其顺序的结果,这种情况下这项技术特别有用。(客户通常期望返回的内容与其出现的顺序相同。)

(4)提供特殊的构造方法来创建链接哈希映射,该哈希映射的迭代顺序就是最后访问其条目的顺序,从近期访问最少到近期访问最多的顺序(访问顺序)。这种映射很适合构建 LRU 缓存。调用 put 或 get 方法将会访问相应的条目(假定调用完成后它还存在)。putAll 方法以指定映射的条目集迭代器提供的键-值映射关系的顺序,为指定映射的每个映射关系生成一个条目访问。任何其他方法均不生成条目访问。特别是,collection 视图上的操作不 影响底层映射的迭代顺序。

可以重写 removeEldestEntry(Map.Entry) 方法来实施策略,以便在将新映射关系添加到映射时自动移除旧的映射关系。

package Map;

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

public class MapTest {
public static void main(String[] args) {
HashMap hMap = new HashMap(2, 0.3f);
LinkedHashMap lMap = new LinkedHashMap(2, 0.3f);

for(int i=0; i<50; i++){
hMap.put(i, "#"+i);
lMap.put(i, "@"+i);
}

System.out.println("****** Print HashMap ******");
int count = 0;
for (Map.Entry entry : hMap.entrySet()) {
String value = entry.getValue();
System.out.printf("%-5s, ",value);
count++;
if(count == 10){
System.out.println();
count = 0;
}
}

System.out.println("\n****** Print LinkedHashMap ******");
count = 0;
for (Map.Entry entry : lMap.entrySet()) {
String value = entry.getValue();
System.out.printf("%-5s, ",value);
count++;
if(count == 10){
System.out.println();
count = 0;
}
}
}
}

output:

****** Print HashMap ******
#0 , #1 , #2 , #3 , #4 , #5 , #6 , #7 , #8 , #9 ,
#10 , #11 , #12 , #13 , #14 , #15 , #17 , #16 , #19 , #18 ,
#21 , #20 , #23 , #22 , #25 , #24 , #27 , #26 , #29 , #28 ,
#31 , #30 , #34 , #35 , #32 , #33 , #38 , #39 , #36 , #37 ,
#42 , #43 , #40 , #41 , #46 , #47 , #44 , #45 , #49 , #48 ,

****** Print LinkedHashMap ******
@0 , @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 ,


1 (数组+链表) + 双向循环链表
public class LinkedHashMap<K,V>
extends HashMap<K,V>
implements Map<K,V>
{

/**
* The head of the doubly linked list.
*/

private transient Entry header;

public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;
}

看到这里,我们只能发现LinkedHashMap是HashMap的子类,它的构造方法也仅仅是调用了父类的构造方法,那么双链表体现在哪儿呢?

首先,HashMap的构造方法都要调用一个方法 init(),而LinkedHashMap对这个方法进行了重写:

    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();
}
    @Override
void init() {
header = new Entry<>(-1, null, null, null);
header.before = header.after = header;
}

如果已经熟悉过HashMap,那么你一定可以发现,这里的Entry<>已经和HashMap中的Entry内部类不一样了,所以,LinkedHashMap重写了HashMap中最基本的单元Entry:

    /**
* LinkedHashMap entry.
*/

private static class Entry<K,V> extends HashMap.Entry<K,V> {
// These fields comprise the doubly linked list used for iteration.
Entry before, after;

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

private void remove() {
before.after = after;
after.before = before;
}

private void addBefore(Entry existingEntry) {
after = existingEntry;
before = existingEntry.before;
before.after = this;
after.before = this;
}

void recordAccess(HashMap m) {
LinkedHashMap lm = (LinkedHashMap)m;
if (lm.accessOrder) {
lm.modCount++;
remove();
addBefore(lm.header);
}
}

void recordRemoval(HashMap m) {
remove();
}
}

看到这段内部类的代码,我们可以总结出两个重要的特性:
(1)这里的 before 和 after 是增加的。此处的Entry它继承了HashMap.Entry,所以它的成员变量有:

  • hash值(这个容易被忽略)
  • key 和 value 两个变量
  • 用于维护哈希表的 next
  • 用于维护双向循环链表的 before 和 after

(3)LinkedHashMap中依然在使用 【桶+链表】 的典型哈希表结构。这一点从它复用的HashMap中的代码可以看出。所以,它依然会有resize,使用的是链表来维护顺序,所以,resize并不会破坏这个顺序的维护。

分析到这里,双链表的特性已经暴露无遗。在接下来分析其他特性的时候,对这个特性的理解会进一步加强,因为这是LinkedHashMap的根本。



2 插入

想要分析这个性质,大家打开LinkedHashMap的Outline,一定会发现,put在哪儿呢? 难道它直接调用HahsMap的put方法?那么怎么会有所不同呢?大家思考一下…………

这里写图片描述

好吧,不卖关子了。其实跟上面的构造方法采用的是同一种方式,所以有一点tricky。

首先,LinkedHashMap确实是调用HashMap的put方法。该方法会调用addEntry方法,addEntry方法会调用createEntry方法。而LinkedHashMap重写这两个方法,而不是直接重写put方法:

//HashMap的put方法
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 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++;
//hash值,k-v对,index,四个参数
//之所以已经知道了index还要传递hash值是因为,如果需要resize,就不必再次计算hash值了,直接根据hash从新计算index就好
------> addEntry(hash, key, value, i);
return null;
}

//addEntry的主要作用是检查是不是需要resize,真的插入操作在createEntry中
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);
}

createEntry(hash, key, value, bucketIndex);
}

啰嗦地提一下HashMap中的put操作,它会先通过hash找到要放入的桶,然后遍历桶中所有元素(如果有的话),看看要插入的节点是否已经存在;如果不存在,则插入到桶的首部,将该节点的next指向旧的桶的首节点

    //LinkedHashMap 中

//此处的addEntry除了负责resize之外,还负责支持LRU,这一点在后面会讲解
void addEntry(int hash, K key, V value, int bucketIndex) {
super.addEntry(hash, key, value, bucketIndex);

// Remove eldest entry if instructed
Entry eldest = header.after;
if (removeEldestEntry(eldest)) {
removeEntryForKey(eldest.key);
}
}

void createEntry(int hash, K key, V value, int bucketIndex) {
//old是要放入的桶中,原本的第一个元素
HashMap.Entry old = table[bucketIndex];
//根据要插入的数据新建的一个entry,但不仅仅是新建一个节点哦:
//**它还将这个要插入的节点的next指向了old: e.next = old**
Entry e = new Entry<>(hash, key, value, old);
table[bucketIndex] = e;
//上面的操作已经完成了哈希表的所有维护工作
//接下来的addBefore则是维护双向循环链表的操作
-----> e.addBefore(header);
size++;
}
//e.addBefore(header)
//典型的,将 e 这个节点插入到双向循环链表的header之前,也就是队尾
private void addBefore(Entry existingEntry) {
after = existingEntry;
before = existingEntry.before;
before.after = this;
after.before = this;
}

这里写图片描述



3 put 已经加入过的key
    public static void main(String[] args) {
LinkedHashMap lMap = new LinkedHashMap(2, 0.3f);

for(int i=0; i<10; i++){
lMap.put(i, "@"+i);
}
for (Map.Entry entry : lMap.entrySet()) {
String value = entry.getValue();
System.out.printf("%-4s",value);
}

String old = lMap.put(3,"##");
System.out.println("\n\nThe old value is: "+old);
for (Map.Entry entry : lMap.entrySet()) {
String value = entry.getValue();
System.out.printf("%-4s",value);
}

}

output:

@0  @1  @2  @3  @4  @5  @6  @7  @8  @9  

The old value is: @3
@0 @1 @2 ## @4 @5 @6 @7 @8 @9

上面的例子说明,如果使用的是默认的插入顺序,再次put之前已经添加过的key,会改变这个key对应的值,但是不会改变整个序列的顺序。这一点可以从 HashMap 的 put 方法看出:

    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 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++;
addEntry(hash, key, value, i);
return null;
}

也就是说,如果put的key是已经存在的(这种表述不严谨,但可以这么理解),它在调用父类HashMap的put方法中就 return oldValue 了,不会有下面的 addEntry 等一系列操作。



4 accessOrder

这个accessOrder在前面的源码中几乎到处都是,但是我先忽略这一部分代码,在这里讲解。

accessOrder是一个boolean变量,它在初始化的时候为false,这时,就是默认的 【插入顺序】;如果为true,表示启用 【访问顺序】。而启用访问顺序只能使用一个特殊的构造方法:

    public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}

我们先来测试一下,再来讲解。

    public static void main(String[] args) {
LinkedHashMap lMap = new LinkedHashMap(2, 0.3f, true);

for (int i = 0; i <10; i++) {
lMap.put(i, "@" + i);
}
System.out.println("**** 初始顺序:****");
for (Map.Entry entry : lMap.entrySet()) {
String value = entry.getValue();
System.out.printf("%-5s", value);
}

// 依次访问81473
lMap.get(8);
lMap.get(1);
lMap.get(4);
lMap.get(7);
lMap.get(3);

System.out.println("\n\n**** 访问过后的顺序:****");
for (Map.Entry entry : lMap.entrySet()) {
String value = entry.getValue();
System.out.printf("%-5s", value);
}

// put 新值
lMap.put(11, "@" + 11);
lMap.put(12, "@" + 12);
lMap.put(13, "@" + 13);
System.out.println("\n\n**** 插入新k-v后的顺序:****");
for (Map.Entry entry : lMap.entrySet()) {
String value = entry.getValue();
System.out.printf("%-5s", value);
}

// put 旧值
lMap.put(0, "new" + 0);
lMap.put(2, "new" + 2);
lMap.put(4, "new" + 4);

System.out.println("\n\n**** 插入旧k后的顺序:****");
for (Map.Entry entry : lMap.entrySet()) {
String value = entry.getValue();
System.out.printf("%-5s", value);
}

}

output:

**** 初始顺序:****
@0 @1 @2 @3 @4 @5 @6 @7 @8 @9

**** 访问过后的顺序:****
@0 @2 @5 @6 @9 @8 @1 @4 @7 @3

**** 插入新k-v后的顺序:****
@0 @2 @5 @6 @9 @8 @1 @4 @7 @3 @11 @12 @13

**** 插入旧k后的顺序:****
@5 @6 @9 @8 @1 @7 @3 @11 @12 @13 new0 new2 new4

实现的原理就是,每次get的时候,将这个元素移到队列的末尾,从而,最不常访问的元素在队列的首部。

核心源码就是 recordAccess方法。这个方法在HashMap中是空的,LinkedHashMap 对这个方法进行了重写。每当调用get 和 put时发现key已存在的情况下就会触发这个方法。

        /**
* This method is invoked by the superclass whenever the value
* of a pre-existing entry is read by Map.get or modified by Map.set.
* If the enclosing Map is access-ordered, it moves the entry
* to the end of the list; otherwise, it does nothing.
*/
void recordAccess(HashMap m) {
LinkedHashMap lm = (LinkedHashMap)m;
if (lm.accessOrder) {
lm.modCount++;
remove();
addBefore(lm.header);
}
}

同理,putAll其实也会触发这个方法,不过putAll不过是顺着传入的参数的迭代器一个个put,所以和put是一样的。

这种顺序的典型应用就是LRU算法。LRU是Least Recently Used 近期最少使用算法。关于操作系统的内存管理,如何节省利用容量不大的内存为最多的进程提供资源,一直是研究的重要方向。而内存的虚拟存储管理,是现在最通用,最成功的方式—— 在内存有限的情况下,扩展一部分外存作为虚拟内存,真正的内存只存储当前运行时所用得到信息。这无疑极大地扩充了内存的功能,极大地提高了计算机的并发度。虚拟页式存储管理,则是将进程所需空间划分为多个页面,内存中只存放当前所需页面,其余页面放入外存的管理方式。

然而,有利就有弊,虚拟页式存储管理减少了进程所需的内存空间,却也带来了运行时间变长这一缺点:进程运行过程中,不可避免地要把在外存中存放的一些信息和内存中已有的进行交换,由于外存的低速,这一步骤所花费的时间不可忽略。

为了尽量减少与理想算法的差距,产生了各种精妙的算法,最少使用页面置换算法便是其中一个。LRU算法的提出,是基于这样一个事实:在前面几条指令中使用频繁的页面很可能在后面的几条指令中频繁使用。反过来说,已经很久没有使用的页面很可能在未来较长的一段时间内不会被用到。这个,就是著名的局部性原理——比内存速度还要快的cache,也是基于同样的原理运行的。因此,我们只需要在每次调换时,找到最少使用的那个页面调出内存。这就是LRU算法的全部内容。

那么,如果要在每次添加新的条目的时候,移除最不常用的条目怎么实现呢? 我觉得说明文档写的很清楚,我直接粘贴过来。

可以重写 removeEldestEntry(Map.Entry) 方法来实施策略,以便在将新映射关系添加到映射时自动移除旧的映射关系。如果此映射移除其最旧的条目,则返回 true。在将新条目插入到映射后, put 和 putAll 将调用此方法。此方法可以提供在每次添加新条目时移除最旧条目的实现程序。如果映射表示缓存,则此方法非常有用:它允许映射通过删除旧条目来减少内存损耗。

示例用法:此重写允许映射增加到 100 个条目,然后每次添加新条目时删除最旧的条目,始终维持 100 个条目的稳定状态。

     private static final int MAX_ENTRIES = 100;

protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > MAX_ENTRIES;
}

此方法通常不以任何方式修改映射,相反允许映射在其返回值的指引下进行自我修改。使用此方法直接修改映射是 允许的,但是如果它执行了此操作,则一定 返回 false(表示该映射不应进行任何进一步的修改)。在此方法中修改映射后是否返回 true 是不确定的。

所以,在LinkedHashMap中,次方法声明为protected本就是为了让子类来重写的。


推荐阅读
  • 开发笔记:加密&json&StringIO模块&BytesIO模块
    篇首语:本文由编程笔记#小编为大家整理,主要介绍了加密&json&StringIO模块&BytesIO模块相关的知识,希望对你有一定的参考价值。一、加密加密 ... [详细]
  • Java容器中的compareto方法排序原理解析
    本文从源码解析Java容器中的compareto方法的排序原理,讲解了在使用数组存储数据时的限制以及存储效率的问题。同时提到了Redis的五大数据结构和list、set等知识点,回忆了作者大学时代的Java学习经历。文章以作者做的思维导图作为目录,展示了整个讲解过程。 ... [详细]
  • LeetCode笔记:剑指Offer 41. 数据流中的中位数(Java、堆、优先队列、知识点)
    本文介绍了LeetCode剑指Offer 41题的解题思路和代码实现,主要涉及了Java中的优先队列和堆排序的知识点。优先队列是Queue接口的实现,可以对其中的元素进行排序,采用小顶堆的方式进行排序。本文还介绍了Java中queue的offer、poll、add、remove、element、peek等方法的区别和用法。 ... [详细]
  • 本文讨论了一个关于cuowu类的问题,作者在使用cuowu类时遇到了错误提示和使用AdjustmentListener的问题。文章提供了16个解决方案,并给出了两个可能导致错误的原因。 ... [详细]
  • 本文详细介绍了Java中vector的使用方法和相关知识,包括vector类的功能、构造方法和使用注意事项。通过使用vector类,可以方便地实现动态数组的功能,并且可以随意插入不同类型的对象,进行查找、插入和删除操作。这篇文章对于需要频繁进行查找、插入和删除操作的情况下,使用vector类是一个很好的选择。 ... [详细]
  • Java学习笔记之面向对象编程(OOP)
    本文介绍了Java学习笔记中的面向对象编程(OOP)内容,包括OOP的三大特性(封装、继承、多态)和五大原则(单一职责原则、开放封闭原则、里式替换原则、依赖倒置原则)。通过学习OOP,可以提高代码复用性、拓展性和安全性。 ... [详细]
  • Java中包装类的设计原因以及操作方法
    本文主要介绍了Java中设计包装类的原因以及操作方法。在Java中,除了对象类型,还有八大基本类型,为了将基本类型转换成对象,Java引入了包装类。文章通过介绍包装类的定义和实现,解答了为什么需要包装类的问题,并提供了简单易用的操作方法。通过本文的学习,读者可以更好地理解和应用Java中的包装类。 ... [详细]
  • 重入锁(ReentrantLock)学习及实现原理
    本文介绍了重入锁(ReentrantLock)的学习及实现原理。在学习synchronized的基础上,重入锁提供了更多的灵活性和功能。文章详细介绍了重入锁的特性、使用方法和实现原理,并提供了类图和测试代码供读者参考。重入锁支持重入和公平与非公平两种实现方式,通过对比和分析,读者可以更好地理解和应用重入锁。 ... [详细]
  • 本文整理了Java面试中常见的问题及相关概念的解析,包括HashMap中为什么重写equals还要重写hashcode、map的分类和常见情况、final关键字的用法、Synchronized和lock的区别、volatile的介绍、Syncronized锁的作用、构造函数和构造函数重载的概念、方法覆盖和方法重载的区别、反射获取和设置对象私有字段的值的方法、通过反射创建对象的方式以及内部类的详解。 ... [详细]
  • HashMap的相关问题及其底层数据结构和操作流程
    本文介绍了关于HashMap的相关问题,包括其底层数据结构、JDK1.7和JDK1.8的差异、红黑树的使用、扩容和树化的条件、退化为链表的情况、索引的计算方法、hashcode和hash()方法的作用、数组容量的选择、Put方法的流程以及并发问题下的操作。文章还提到了扩容死链和数据错乱的问题,并探讨了key的设计要求。对于对Java面试中的HashMap问题感兴趣的读者,本文将为您提供一些有用的技术和经验。 ... [详细]
  • 本文介绍了在Android开发中使用软引用和弱引用的应用。如果一个对象只具有软引用,那么只有在内存不够的情况下才会被回收,可以用来实现内存敏感的高速缓存;而如果一个对象只具有弱引用,不管内存是否足够,都会被垃圾回收器回收。软引用和弱引用还可以与引用队列联合使用,当被引用的对象被回收时,会将引用加入到关联的引用队列中。软引用和弱引用的根本区别在于生命周期的长短,弱引用的对象可能随时被回收,而软引用的对象只有在内存不够时才会被回收。 ... [详细]
  • Go GUIlxn/walk 学习3.菜单栏和工具栏的具体实现
    本文介绍了使用Go语言的GUI库lxn/walk实现菜单栏和工具栏的具体方法,包括消息窗口的产生、文件放置动作响应和提示框的应用。部分代码来自上一篇博客和lxn/walk官方示例。文章提供了学习GUI开发的实际案例和代码示例。 ... [详细]
  • Java在运行已编译完成的类时,是通过java虚拟机来装载和执行的,java虚拟机通过操作系统命令JAVA_HOMEbinjava–option来启 ... [详细]
  • 李逍遥寻找仙药的迷阵之旅
    本文讲述了少年李逍遥为了救治婶婶的病情,前往仙灵岛寻找仙药的故事。他需要穿越一个由M×N个方格组成的迷阵,有些方格内有怪物,有些方格是安全的。李逍遥需要避开有怪物的方格,并经过最少的方格,找到仙药。在寻找的过程中,他还会遇到神秘人物。本文提供了一个迷阵样例及李逍遥找到仙药的路线。 ... [详细]
  • 本文介绍了使用Spark实现低配版高斯朴素贝叶斯模型的原因和原理。随着数据量的增大,单机上运行高斯朴素贝叶斯模型会变得很慢,因此考虑使用Spark来加速运行。然而,Spark的MLlib并没有实现高斯朴素贝叶斯模型,因此需要自己动手实现。文章还介绍了朴素贝叶斯的原理和公式,并对具有多个特征和类别的模型进行了讨论。最后,作者总结了实现低配版高斯朴素贝叶斯模型的步骤。 ... [详细]
author-avatar
影帝
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有