干货有质量,水文有情怀,微信搜索【程序控】,关注这个有趣的灵魂
【本篇是集合中的Map篇,以下涉及源码基于JDK1.8】
(为何上一篇介绍了Collection下的list,不接着介绍Collection下的set,因为Set集合实际上就是HashMap来构建的!没看过List篇的移步这里了)
本篇是Java基础中最重要的知识点集合Map篇。Java集合是java提供的工具包,包含了常用的数据结构:集合、链表、队列、栈、数组、映射等。Java集合工具包位置是java.util.*,Java集合主要可以划分为4个部分:List列表、Set集合、Map映射、工具类(Iterator迭代器、Enumeration枚举类、Arrays和Collections)。接下来我们一起学习吧,博主水平有限,哪里有不对的地方欢迎大佬指出斧正。
Colletcion和Map结构如下图所示:
大致介绍一下:Collection和Map是两个高度抽象的接口;
- Collection抽象的是集合,包含了集合的基本操作和属性,Collection主要包含List和Set两大分支。List是有序的链表,允许存储重复的元素,List的主要实现类有LinkedList, ArrayList, Vector, Stack。Set是不允许存在重复元素的集合,Set的主要实现类有HastSet和TreeSet(依赖哈希实现,后面介绍)。
- Map是一个映射接口,即存储Key-Value键值对的集合(和redis存储类似),AbstractMap是个抽象类,它实现了Map接口中的大部分API,而常见的HashMap,TreeMap都是继承于AbstractMap。HashTable继承于Dictionary,但也实现了Map接口。
集合是Java中用来存储多个对象的一个容器,我们知道容器数组,数组长度不可变,且只能存储同样类型的元素,数组可以存储基本类型或者引用类型;而集合长度可变,可以存储不同类型元素(但是我们一般不这么干),集合只能存储引用类型(存储的基本类型会变成包装类);
集合的Fail-Fast机制?
fail-fast 机制是java集合(Collection)中的一种错误机制。当多个线程对同一个集合的内容进行操作时,就可能会产生 fail-fast 事件。例如:当某一个线程A通过 iterator 去遍历某集合的过程中,若该集合的内容被其他线程所改变了;那么线程A访问集合时,就会抛出ConcurrentModificationException 异常,产生 fail-fast 事件;当然,不仅是多个线程,单个线程也会出现 fail-fast 机制,比如单线程下的iterator迭代器遍历时调用集合的增删改等操作会抛出java.util.ConcurrentModificationException,从而产生fail-fast机制。
HashMap
HashMap定义:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable { }
HashMap简介:
-
HashMap是基于Map接口的Key-Value的集合,允许使用null值和null键,但不保证映射的顺序,特别是它不保证该顺序恒久不变。HashMap在底层将Key-Value当成一个整体Entry来处理。
-
底层使用数组实现,数组中每一项是单向链表,即数组和链表的结合体;链表长度大于一定阈值时,链表转换为红黑树。后面详细分析源码介绍
-
在链表和数组中可以说是有序的(存储的顺序和取出的顺序是一致的),但同时也带来缺点:想要获取某个元素便要访问所有的元素直到找到为止(List中知道具体位置可以直接访问)。HashMap可以不在意元素的顺序,能够快速的查找定位到元素。
-
散列表HashMap会为每一个Key计算出哈希值,即散列码,根据这些散列码保存在对应的位置上。如下图所示的数组+链表实现,一个hash值会遇到被占用的情况(hashCode散列码相同,就存储在同一个位置上),这种情况是无法避免的,这种现象称之为:散列冲突。后面详细介绍冲突以及解决方法等
HashMap源码及重点关注问题:
- 属性和默认值:
注意:初始容量太高和负载因子太低的话,遍历效率不太好。
- 构造函数:
这里我们需要注意第一个构造函数,传入初始容量和负载因子,如果初始容量小于0抛出异常,大于最大容量MAXIMUM_CAPACITY则置为MAXIMUM_CAPACITY;如果不是这俩个会进入tableSizeFor()方法,一起来看看:
int n = cap - 1;
为什么要有第一步的减一操作?目的是为了防止传入的cap已经是2的幂,如果没有执行这个减一操作,返回的将是这个cap(传入是2的幂)的2倍,不太懂?请继续读下去。
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
重点:如果传入的cap为0,经过减一和后面几次的无符号移动之后会一直是0,最后返回capacity是1;主要考虑cap不为0的情况,由于cap不为0,则n的二进制中总会有一个bit位为1,我们只考虑最高bit位为1。 第一步n >>> 1是无符号右移1位,再做或运算,使得二进制中的最高位和紧邻的右边一位也是1(0000 11## #### ####这种格式)。 第二步n >>> 2是无符号右移两位再做或操作,第一步已经将最高位和其临位变为1,这一步则会将从高位开始的4位都变成1(0000 1111 #### ####这种格式)。 第三步n >>> 4是无符号右移4位再做或操作,同样的道理,经过这一步之后从最高位开始后面的8位都变为1(0000 1111 1111 ####这种格式)。 以此类推,容量最大是32位正数,经过n |= n >>> 16之后最多也就32个1(1+2+4+8+16=31,但是别忘了开始的那一位),此时已经是负数了。在执行tableSizeFor之前,对initialCapacity做了判断,如果大于MAXIMUM_CAPACITY(2 ^ 30),则取MAXIMUM_CAPACITY。如果等于MAXIMUM_CAPACITY(2 ^ 30),会执行移位操作。所以这里面的移位操作之后,最大30个1,不会大于等于MAXIMUM_CAPACITY。30个1,加1之后得2 ^ 30) 。 举个例子: 现传入一个数cap为11,n=cap-1为10,则n二进制为0000 1010。 n |= n >>> 1:n >>> 1变为0000 0101,n或n>>> 1变为0000 1101,n即此值; n |= n >>> 2:n >>> 2变为0000 0011,n或n>>> 2变为0000 1111,n即此值; n |= n >>> 4:n >>> 4变为0000 0000,n或n>>> 4变为0000 1111,n不变; n |= n >>> 8:n >>> 8变为0000 0000,n或n>>> 8变为0000 1111,n不变; n |= n >>> 16:n >>> 16变为0000 0000,n或n>>> 16变为0000 1111,n不变;
看完上面可能会感到奇怪的是:为啥是将2的整数幂的数赋给threshold?
threshold这个成员变量是阈值,决定了是否要将散列表再散列。它的值应该是:capacity * load factor才对的。其实这里仅仅是一个初始化,当创建哈希表的时候,它会重新赋值的。
- 内部类Node:
- put()方法--放入(Key,Value)键值对:
内部包含hash()函数和putVal()函数,hash函数用来求哈希值,putVal()函数用来将key-value键值对放入集合map,我们先看hash()函数:
这里直接将key的哈希值返回不就好了, 为什么还要做异或运算? 我们进入putVal()函数看:
这里是根据key的哈希值来保存在散列表中的,刚刚上面的key原哈希值和高16位异或运算得到的新哈希值便是用来计算保存在散列表中位置的。我们表的默认初始容量是16,要放到散列表中即为0-15的位置,即tab[i = (n - 1) & hash]这里。我们可以发现在做&运算的时候,仅仅是后4位有效(容量32则是后5位有效,依次类推),那如果我们key的哈希值高位变化很大,低位变化很小,直接拿过去做&运算,这就会导致计算出来的Hash值相同的很多。 而设计者将key的哈希值的高位也做了运算(与高16位做异或运算,使得在做&运算时,此时的低位实际上是高位与低位的结合),这就增加了随机性,减少了碰撞冲突的可能性! 详细理解下HashMap的put()过程吧:
put源码中还可以挖掘更多细节,比如putTreeVal()在树中插入节点、treeifyBin()转变树结构等,感兴趣的可以多多研究,关于红黑树结构及原理,文章下面会介绍。
- resize()方法--扩容机制:
当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。 那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过160.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。
- get()方法--获取key对应的value:
hash()计算key对应的哈希值,上面介绍过,我们看getNode():
这里只讲解了最基本的函数的源码,很多常用函数我们完全没必要全部都阅读源码,学会使用,了解原理即可,但是阅读源码是一种优秀的习惯,大家还是要习惯性的养成。
HashMap中的树存储
在介绍红黑树之前我们要先理解二叉查找树的数据结构。
如上图就是一个二叉查找树。二叉查找树有如下几条特性
- 左子树上所有结点的值均小于或等于它的根结点的值。
- 右子树上所有结点的值均大于或等于它的根结点的值。
- 左、右子树也分别为二叉排序树
那既然他名字中是有“查找”的,那么他是怎么查找的呢?比如我们要查找10这个元素,查找过程为:首先找到根节点,然后根据二叉查找树特性,我们知道要查找的10>9所以是在根节点的右边去查找,找到13,10<13,所以接着在13的左边找,找到11,10<11,继续在11的左边查找,这样就找到了10,这其实就是二分查找的思想。最后我们要查出结果所需的最大次数就是二叉树的高度!(二分查找的思想:找到数组的中间位置的元素v,将数组分成>v和<v两部分,然后将v和要查找的数据进行一个比较,如果大于v那么就在>v的部分再次进行二分查找,否则就在<v的部分进行二分查找,直到找到对应的元素。) 那既然要查出结果所需的最大次数就是二叉树的高度,那这个高度会不会有时候很长呢?比如我们依次插入根节点为9如下节点:7,6,5,4,3,2,1。依照二叉查找树的特性,结果会变成什么样呢?7,6,5,4,3,2,1一个比一个小,那么就会成一条直线,也就是成为了线性的查询,时间复杂度变成了O(N)级别。为了解决这种情况,该红黑树出场了。
红黑树:
红黑树其实就是一种自平衡的二叉查找树。他这个自平衡的特性就是对HashMap中链表可能会很长做出的优化。红黑树是每个节点都带有颜色属性的二叉查找树,颜色或红色或黑色。
-
节点是红色或黑色。
-
根节点是黑色。
-
每个叶节点(NIL节点,空节点)是黑色的。
-
每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
-
从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
红黑树那么多规则,那么在插入和删除元素会不会破坏红黑树的规则呢?答案是肯定的,红黑树通过“变色”和“旋转”来维护红黑树的规则,变色就是让黑的变成红的,红的变成黑的,旋转又分为“左旋转”和“右旋转”。(具体这里不详细介绍,在数据结构篇讲解)
HashMap底层到底在何时转换成红黑树?
很多博文中只提到了链表长度大于八的条件,实际上是需要两个条件的:
- 链表长度大于8,官方源码如下:
- 当满足条件1以后调用treeifyBin方法转化红黑树。该方法中,数组如果长度小于MIN_TREEIFY_CAPACITY(64)就选择扩容,而不是转化为红黑树。
HashMap重点关注问题:
HashMap特点和应用场景介绍?
- HashMap是实现了Map接口的Key-Value存储类型(Entry类型)的集合,这些键值对分散在数组中;
- HashMap中的Key是唯一的,不允许重复。允许null键值对。
- HashMap中的元素是无序的,按照哈希值找位置。不是现场安全的。
哈希冲突是什么?如何解决?
哈希表是基于数组的一种存储方式,由哈希函数和数组构成。当存储数据时,先根据函数计算出数据的地址,然后放入数组的特定地址位置里,这个函数是哈希函数。 哈希冲突很容易理解,其实就是指的是这个计算出来的位置被别人占用了的时候如何处理。举个例子,上面的HashMap是通过哈希值来寻找数组位置的,我存储(key1-va1)(key2-va2)两个键值对,若key1计算的哈希值是0,在数组第一个位置存储,然后计算key2的哈希值也是0,这时这个位置有key1了,总不能把key2覆盖key1吧,两个键值对的key值不一样,不符合。如何处理这种情况?
-
开放地址法:开放地址之线性探测法,出现Hash冲突后,依次查询这个键值后面的地址,找到一个空的或者全部查完没找到。开放地址之二次探查法,出现冲突后,对这个键值后面的地址或者前面的地址进行平方后查询。
-
再哈希法:构建多个Hash算法函数,出现冲突就用其他Hash算法进行Hash,直到不冲突为止。
-
链表法:把哈希冲突冲突的元素都放在一个链表中,但是链表的长度不能太长,越长效率越慢。(HashMap采用这个方法,但是链表有可能转化成红黑树)
-
建立公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表,比较粗暴。
HashMap在多线程中存在的问题?如何解决?
网上很多博客讲述的是HashMap中的死循环问题,其实HashMap中存在很多并发问题,因为HashMap设计的目的和应用场所就没有考虑并发场景。HashMap 的设计目标是简洁高效,没有采取任何措施保证 put、remove 操作的多线程安全。在各种操作的过程中都有可能存在多线程并发问题,在这些复杂的逻辑中有任何一个线程改变了散列表的结构就有可能出现并发问题。 在jdk1.8之前HashMap经常会出现死循环导致CPU利用率较高等问题,在jdk1.8及之后改善了这个问题,但是不代表不存在并发问题,多线程put时可能导致元素的丢失,put非null元素后get出来的却是null等情况都有可能会出现。 在多线程环境下如何使用呢?三种方法:
- Collections.synchronizedMap(new HashMap<String , Object>()):可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力。底层是对每一个方法使用锁,让每一个方法的操作都具有原子性,更具有安全性。
- Hashtable:Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap,Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。
- ConcurrentHashMap :ConcurrentHashMap 与 HashMap 相比,在高效率的基础上添加了对多线程安全的保证。为了这个多线程安全,ConcurrentHashMap 并没有采用 HashMap 的单散列表设计,而是引入了分段散列表来平衡多线程安全和高效率,核心思想就是尽量只锁住需要操作的散列表段,而不整个散列表。
接下来详细说明,大家坐好,准备发车了。
在jdk1.8之前的死循环问题,HashMap的线程不安全主要是发生在扩容函数中,根源是在transfer函数中,JDK1.7中HashMap的transfer()如下:
这一段就是HashMap中扩容操作的关键,重新哈希定位位置,并采用头插法(头插法会将链表的顺序翻转,这也是形成死循环的关键点。)迁移到新数组中。我们来仔细看下这个过程:假设现在有两个线程A、B,同时执行上面的扩容操作;
如果正常扩容的结果应该是:
但是我们考虑一种情况,当线程A执行到上面的代码中的newTable[i] = e,CPU时间片耗尽,线程A被挂起。
如上图所示,此时的线程A中:e = 3,next = 7,e.next此时为null(指针newTable[i]还未赋值)。
此时线程A的CPU时间片耗尽,CPU开始执行线程B,并在线程B中成功完成数据迁移,即上述代码执行完成。此时线程B处于正确的状态,如下图所示:
我们根据Java内存模型可知,线程B完成数据迁移之后,此时主存中的数据newTable和table都是最新的,即7.next = 3,3.next = 7。随后线程A获得CPU的执行权,继续执行newTable[i] = e,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下:
紧接着线程A执行下一个循环,此时e = 7,从主内存中读取e.next时发现主内存中7.next= 3,于是乎next=3,并将7放入新数组,并继续执行完此轮循环。
于是乎,执行下一次循环,next=e.next=null,最后一轮循环,接下来当执行完e.next=newTable[i]即3.next=7后,3和7之间就相互连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中。
如上图所示,3和7的指针互相指向了,形成死循环了。并且数据5也丢失了。
循环的产生是因为新链表的顺序跟旧的链表是完全相反的,所以只要保证建新链时还是按照原来的顺序的话就不会产生循环。JDK8是用 head 和 tail 来保证链表的顺序和之前一样,这样就不会产生循环引用。
虽然jdk1.8及之后解决了这个循环的问题,但是仍然会有很多的并发问题,比如数据覆盖,这里我们不一一列举。总之,HashMap不是设计在多线程的环境下,也不太适合用于多线程的环境下,如果需要在多线程环境下使用,可以使用我们介绍的Collections.synchronizedMap(map)或者ConcurrentHashMap这两种比较常用的法子。
这里我们引申几个问题,如何判断链表有环?如何判断两个单链表是否相交?
如何判断链表有环:简单介绍个方法,暂不详细介绍。创建指针1 和指针2 ,同时指向链表的头节点,指针1每次循环向下移动一个指针,指针2每次向下移动2个指针,然后比较两个指针指向的节点是否相同。如果相同,则判断出链表有环,如果不同,则继续下一次循环。 如何判断两个单链表是否相交:如果两个链表相交,那么两个链表的尾结点的地址也是一样的。如何找到第一个相交结点?判断是否相交的时候,记录下两个链表的长度,算出长度差len,接着先让较长的链表遍历len个长度,然后两个链表同时遍历,判断是否相等,如果相等,就是第一个相交的结点。
HashMap的遍历?
- 遍历HashMap的键值对:根据entrySet()获取HashMap的“键值对”的Set集合,通过Iterator迭代器遍历“第一步”得到的集合;
// map中的key是String类型,value是String类型
String value = null;
Iterator iter = map.entrySet().iterator();
while(iter.hasNext()) {
Map.Entry entry = (Map.Entry)iter.next();
// 获取key
key = (String)entry.getKey();
// 获取value
value = entry.getValue();
}
- 遍历HashMap的键:根据keySet()获取HashMap的“键”的Set集合,通过迭代器遍历得到集合;
// map中的key是String类型,value是String类型
String key = null;
String value = null;
Iterator iter = map.keySet().iterator();
while (iter.hasNext()) {
// 获取key
key = (String)iter.next();
// 根据key,获取value
value = map.get(key);
}
- 遍历HashMap的值:根据values()获取HashMap的“值”的集合,通过Iterator迭代器遍历“第一步”得到的集合。
// map中的key是String类型,value是String类型
String value = null;
Collection c = map.values();
Iterator iter= c.iterator();
while (iter.hasNext()) {
value = iter.next();
}
Hashtable(已过时,了解即可)
Hashtable定义:
public class Hashtable<K,V>
extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable { }
Hashtable简介:
- HashTable同样是基于哈希表实现的,同样每个元素都是key-value对,其内部也是通过单链表解决冲突问题,容量不足(超过了阈值)时,同样会自动增长。
- Hashtable也是JDK1.0引入的类,是线程安全的,能用于多线程环境中。
- HashTable和HashMap从存储结构和实现来讲基本上都是相同的。它和HashMap的最大的不同是它是线程安全的,另外它不允许key和value为null。
- Hashtable是个过时的集合类,不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。
HashTable的源码不再详细的分析了,感兴趣的可以自己去看看,没有必要详细的研究,很多方法和HashMap一样,只是加了synchronized关键字实现多线程安全,synchronized是针对整张Hash表的,即每次锁住整张表让线程独占。
TreeMap
TreeMap定义:
public class TreeMap<K,V>
extends AbstractMap<K,V>
implements NavigableMap<K,V>, Cloneable, java.io.Serializable { }
TreeMap简介:
- TreeMap 是一个有序的key-value集合(根据元素的Key进行排序),不允许key为null,TreeMap实现了NavigableMap接口,而NavigableMap接口继承着SortedMap接口,致使我们的TreeMap是有序的。
- TreeMap底层是红黑树,它方法的时间复杂度都不会太高:log(n)。
- TreeMap基于红黑树(Red-Black tree)实现。该映射根据其键的自然顺序进行排序,或者根据创建映射时提供的 Comparator 进行排序,具体取决于使用的构造方法。
- TreeMap是非同步的。 它的iterator 方法返回的迭代器是fail-fastl的。
- TreeMap的本质是红黑树,它包含几个重要的成员变量: root, size, comparator。root 是红黑数的根节点。它是Entry类型,Entry是红黑数的节点,它包含了红黑数的6个基本组成成分:key(键)、value(值)、left(左孩子)、right(右孩子)、parent(父节点)、color(颜色)。Entry节点根据key进行排序,Entry节点包含的内容为value。
TreeMap核心函数:
- TreeMap属性:
- 构造函数:
// 默认构造函数。使用该构造函数,TreeMap中的元素按照自然排序进行排列。
public TreeMap() {
comparator = null;
}
// 创建的TreeMap包含Map
public TreeMap(Map<? extends K, ? extends V> m) {
comparator = null;
putAll(m);
}
// 指定Tree的比较器
public TreeMap(Comparator<? super K> comparator) {
this.comparator = comparator;
}
// 创建的TreeSet包含copyFrom
public TreeMap(SortedMap<K, ? extends V> m) {
comparator = m.comparator();
try {
buildFromSorted(m.size(), m.entrySet().iterator(), null, null);
} catch (java.io.IOException cannotHappen) {
} catch (ClassNotFoundException cannotHappen) {
}
}
我们可以看出每个构造方法内部都指定了Comparator,TreeMap有序是通过Comparator来进行比较的,如果comparator为null,那么就使用自然顺序。如果value是整数,自然顺序指的就是我们平常排序的顺序1、2、3、4、5这种。
3. put()方法--放入对象:
4. get()方法--根据Key获得Value:
接着我们进入getEntry()方法研究研究:
5. remove()方法:
删除节点的时候调用的是deleteEntry(Entry<K,V> p)方法,这个方法主要是删除节点并且平衡红黑树。
- clear()函数:
TreeMap重点关注问题:
TreeMap特点,应用场景?
- TreeMap 实现了一个红黑树结构的map,实现了有序存取。查找、插入、删除在最坏情况下的复杂度都是O(lgN)。
- TreeMap中所有的元素都保持着某种固定的顺序,如果你需要得到一个有序的结果你就应该使用TreeMap(HashMap中元素的排列顺序是不固定的)。
TreeMap遍历?
TreeMap的Iterator有非常非常多,其实我们debug下去就可以看出底层都是使用EntryIterator这个内部类。
LinkedHashMap
LinkedHashMap定义:
public class LinkedHashMap<K,V>
extends HashMap<K,V>
implements Map<K,V> { }
LinkedHashMap简介:
- LinkedHashMap继承自HashMap,同时在HashMap的基础上增加了一个双向链表,我们可以认为是HashMap+LinkedList,它可以使用HashMap操作数据结构,又使用LinkedList维护插入元素的先后顺序。
- HashMap中存在一个无序的问题,即迭代HashMap的顺序并不是HashMap的放置顺序,LinkedHashMap可以根据元素增加或者访问的先后顺序进行排序。有人可能会疑问:你上面不是刚说了一个有序的TreeMap,怎么又来了个有序的LinkedHashMap?TreeMap是根据Key来进行排序的,当然排序规则可以由个人来定,和LinkedHashMap的着重点是不一样的。
- LinkedHashMap增加了时间和空间上的开销,底层通过一个一个双向链表保证了元素迭代的顺序,迭代顺序可以是插入顺序或者访问顺序。
- LinkedHashMap允许Key和Value为空,非线程安全的。
- 上面图为LinkedHashMap整体结构图,内部的循环双向链表的头部存放的是最久访问的节点或最先插入的节点,尾部为最近访问的或最近插入的节点,迭代器遍历方向是从链表的头部开始到链表尾部结束,在链表尾部有一个空的header节点,该节点不存放key-value内容,为LinkedHashMap类的成员属性,循环双向链表的入口。
LinkedHashMap核心函数:
- LinkedHashMap属性:
- 构造方法:
// 构造一个初始容量和负载因子的、按照插入顺序的LinkedList
public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;
}
// 构造一个初始容量的LinkedHashMap,取得键值对的顺序是插入顺序
public LinkedHashMap(int initialCapacity) {
super(initialCapacity);
accessOrder = false;
}
// 用默认的初始化容量和负载因子创建一个LinkedHashMap,取得键值对的顺序是插入顺序
public LinkedHashMap() {
super();
accessOrder = false;
}
// 通过传入的map创建一个LinkedHashMap,容量为默认容量(16)和(map.zise()/DEFAULT_LOAD_FACTORY)+1的较大者,装载因子为默认值
public LinkedHashMap(Map<? extends K, ? extends V> m) {
super();
accessOrder = false;
putMapEntries(m, false);
}
// 根据指定容量、装载因子和键值对保持顺序创建一个LinkedHashMap
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
我们可以看出前四个都默认了accessOrder 参数为false,则链表的顺序则是插入顺序,只有最后一个构造方法,可以传入accessOrder 参数,true的时候则是按照访问顺序来排序底层链表的。
3. put()方法:
是不是看着眼熟~~,没错,就是HashMap的put方法,我们调用LinkedHashMap的put方法的底层会调用HashMap的put方法来操作;如果不忘了的话,向上滚动!!
当然了,其中在putVal()创建节点的时候,调用的是LinkedHashMap重写的方法。
4. get()方法:
进去afterNodeAccess方法看:
afterNodeAccess内部操作链表,将节点插入到链表的最后面;
5. remove方法:对于remove方法,在LinkedHashMap中没有重写,它调用的是父类的HashMap的remove()方法,在LinkedHashMap中重写的是:afterNodeRemoval(Node<K,V> e)这个方法,我们看下HashMap的内部就明白了:
接下来我们看afterNodeRemoval(Node<K,V> e)这个方法:
我们来测试一波LinkedHashMap的插入顺序和访问顺序的遍历:
LinkedHashMap<Integer, String> test = new LinkedHashMap<>();
String value = "点赞关注不迷路~";
for (int i = 0; i < 5; i++) {
test.put(i, value);
}
// 遍历
Set<Integer> set = test.keySet();
for (Integer s : set) {
String mapValue = test.get(s);
System.out.println(s + "---" + mapValue);
}
得到测试结果:(插入的顺序和我们遍历得到的顺序一样,即默认false的插入顺序)
接着,我们来测试一下以访问顺序来进行插入和遍历:
代码看似是没有问题,但是运行会出错的!在顺序访问下,使用get方法获取value值也是结构性的修改;换种方式来看看效果:
-
这个访问顺序在LinkedHashMap如果不重写个人感觉用处并不大,因为最常被使用的元素再遍历的时候却放在了最后边,在LinkedHashMap中我也没找到对应的方法来进行调用。
-
一个removeEldestEntry(Map.Entry<K,V> eldest)方法,重写它可以删除最久未被使用的元素。
-
还有一个是afterNodeInsertion(boolean evict)方法,新增时判断是否需要删除最久未被使用的元素。
- 利用LinkedHashMap实现LRU算法缓存:
不太懂LRU算法?🆗,我先来科普下,坐稳~ LRU,即Least Recently Used,最近最久未使用法。即最近使用的页面数据会在未来一段时期内仍然被使用,已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。比方说数据a,1天前访问了;数据b,2天前访问了,缓存满了,优先会淘汰数据b。基于这个思想可以实现一种缓存的淘汰机制,感兴趣的可以了解下这篇Redis系列文章。 在计算机中大量使用了这个机制,它的合理性在于优先筛选热点数据,所谓热点数据,就是最近最多使用的数据!因为,利用LRU我们可以解决很多实际开发中的问题,并且很符合业务场景。 如下代码所示,一个简单的缓存:
public class LRUCache extends LinkedHashMap
{
private static final long serialVersionUID = 1L;
protected int maxElements;
public LRUCache(int maxSize)
{
super(maxSize, 0.75F, true);
//传入指定的缓存最大容量
maxElements = maxSize;
}
//实现LRU的关键方法,如果map里面的元素个数大于了缓存最大容量,则删除链表的顶端元素
protected boolean removeEldestEntry(java.util.Map.Entry eldest)
{
return size() > maxElements;
}
}
这个类继承自LinkedHashMap,而类中看到没有什么特别的方法,这说明LRUCache实现缓存LRU功能都是源自LinkedHashMap的。LinkedHashMap可以实现LRU算法的缓存基于两点:一是LinkedList首先它是一个Map,Map是基于K-V的,和缓存一致。二是提供了上述的第五个构造方法,可以让用户指定实现LRU。
- LinkedHashMap的数据结构,特点?
- LinkedHashMap 是LinkedList+HashMap,它是线程不安全的,允许key为null,value为null。 继承自HashMap,内部维护了一个双向链表。
- LinkedHashMap可以设置两种遍历顺序:访问顺序(access-ordered)和插入顺序(insertion-ordered),默认是插入顺序的。
- 对于访问顺序,它是LRU(最近最少使用)算法的实现,要使用它要么重写LinkedListMap的几个方法(removeEldestEntry(Map.Entry<K,V> eldest)和afterNodeInsertion(boolean evict)),要么是扩展成LRUMap来使用,不然设置为访问顺序(access-ordered)的用处不大。
- LinkedHashMap遍历的是内部维护的双向链表,所以说初始容量对LinkedHashMap遍历是不受影响的。
IdentityHashMap
- 简单说常用的HashMap和IdentityHashMap的区别是:前者比较key时是“引用相等”而后者是“对象相等”,即对于k1和k2,当k1==k2时,IdentityHashMap认为两个key相等,而HashMap只有在k1.equals(k2) == true 时才会认为两个key相等。
- IdentityHashMap 允许使用null作为key和value. 不保证任何Key-value对的之间的顺序, 更不能保证他们的顺序随时间的推移不会发生变化。
- IdentityHashMap有其特殊用途,比如序列化或者深度复制。或者记录对象代理。
举个例子,jvm中的所有对象都是独一无二的,哪怕两个对象是同一个class的对象,而且两个对象的数据完全相同,对于jvm来说,他们也是完全不同的,如果要用一个map来记录这样jvm中的对象,你就需要用IdentityHashMap,而不能使用其他Map实现。
ConcurrentHashMap
ConcurrentHashMap定义:
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable { }
ConcurrentHashMap简介:
ConcurrentHashMap是在JDK1.5时,J.U.C引入的一个同步集合工具类,顾名思义,这是一个线程安全的HashMap。不同版本的ConcurrentHashMap,内部实现机制千差万别,本节所有的讨论基于JDK1.8。
- ConCurrentHashMap的底层是:散列表+红黑树,与HashMap是一样的。ConCurrentHashMap支持高并发的访问和更新,它是线程安全的。
- 检索操作不用加锁,get方法是非阻塞的,其中key和value都不允许为null。
图片来源于网络百度,侵删~
- ConcurrentHashMap实现了ConcurrentMap这个接口,ConcurrentMap是在JDK1.5时随着J.U.C包引入的,这个接口其实就是提供了一些针对Map的原子操作。
- 基本结构介绍:ConcurrentHashMap内部维护了一个Node类型的数组,也就是table。数组的每一个位置table[i]代表了一个桶,当插入键值对时,会根据键的hash值映射到不同的桶位置,table一共可以包含4种不同类型的桶:Node、TreeBin、ForwardingNode、ReservationNode。上图中,不同的桶用不同颜色表示。可以看到,有的桶链接着链表,有的桶链接着树,这也是JDK1.8中ConcurrentHashMap的特殊之处。
- TreeBin所链接的是一颗红黑树,红黑树的结点用TreeNode表示,所以ConcurrentHashMap中实际上一共有五种不同类型的Node结点。之所以用TreeBin而不是直接用TreeNode,是因为红黑树的操作比较复杂,包括构建、左旋、右旋、删除,平衡等操作,用一个代理结点TreeBin来包含这些复杂操作,其实是一种“职责分离”的思想。另外TreeBin中也包含了一些加/解锁的操作。
JDK1.7的底层实现:
上面说的的是JDK1.8底层是:散列表+红黑树,但是JDK1.7的底层是:segments+HashEntry数组:
Segment继承了ReentrantLock,每个片段都有了一个锁,叫做“锁分段”。ConcurrentHashMap将哈希表分成许多片段(segments),每一个片段(table)都类似于HashMap,它有一个HashEntry数组,数组的每项又是HashEntry组成的链表。每个片段都是Segment类型的,Segment继承了ReentrantLock,所以Segment本质上是一个可重入的互斥锁。这样每个片段都有了一个锁,这就是“锁分段”。线程如想访问某一key-value键值对,需要先获取键值对所在的segment的锁,获取锁后,其他线程就不能访问此segment了,但可以访问其他的segment。
感兴趣的可以自行研究,不多介绍,接下来重点分析1.8~
ConcurrentHashMap核心函数:
在介绍这个之前,先铺垫一下:
有了Hashtable为啥需要ConCurrentHashMap ,HashTable不是已经是线程安全的了吗?
Hashtable是在每个方法上都加上了Synchronized完成同步,效率低下。ConcurrentHashMap通过在部分加锁和利用CAS算法来实现同步。
CAS算法和volatile简单介绍(这里在并发篇会重点讲解,这里不详细讲解)
CAS(比较与交换,Compare and swap) 是一种有名的无锁算法,CAS有3个操作数:
- 内存值V
- 旧的预期值A
- 要修改的新值B
当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值(A和内存值V相同时,将内存值V修改为B),而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试(否则什么都不做)。其实简单一句话概括就是:先比较是否相等,如果相等则替换(CAS算法)。
接下来我们看看volatile关键字,可能很多人已经了解volatile关键字的作用,这也是面试常问的点之一。 volatile经典总结:volatile仅仅用来保证该变量对所有线程的可见性,但不保证原子性。 我们将其拆开来解释一下:
- 保证该变量对所有线程的可见性:在多线程的环境下:当这个变量修改时,所有的线程都会知道该变量被修改了,也就是所谓的“可见性”
- 不保证原子性:修改变量(赋值)实质上是在JVM中分了好几步,而在这几步内(从装载变量到修改),它是不安全的。
简单说一下ConcurrentHashMap的5个节点的定义和作用
- Node结点:Node结点的定义非常简单,也是其它四种类型结点的父类。默认链接到table[i]——桶上的结点就是Node结点。当出现hash冲突时,Node结点会首先以链表的形式链接到table上,当结点数量超过一定数目时,链表会转化为红黑树。
- TreeNode结点:TreeNode就是红黑树的结点,TreeNode不会直接链接到table[i]——桶上面,而是由TreeBin链接,TreeBin会指向红黑树的根结点。
- TreeBin结点:TreeBin相当于TreeNode的代理结点。TreeBin会直接链接到table[i]——桶上面,该结点提供了一系列红黑树相关的操作,以及加锁、解锁操作。
- ForwardingNode结点:ForwardingNode结点仅仅在扩容时才会使用。
- ReservationNode结点:保留结点,ConcurrentHashMap中的一些特殊方法会专门用到该类结点。
接下来我们来看ConcurrentHashMap的内部源码(其实在看源码的时候我们可以有规律的看,如下图所示有分割线这种):
内部的常量很多和HashMap是一样的,这里我们不多介绍了。
- 域对象:
- 构造函数:
可以发现,在构造方法中有几处都调用了tableSizeFor(),我们来看一下他是干什么的:
哎,怎么如此熟悉?哦,对了!这不就是HashMap中用来获取大于参数且最接近2的整次幂的数,赋值给sizeCtl属性也就说明了:这是下次扩容的大小。
- put()方法--放入元素:
结构也和HashMap的put方法类似,点进去putVal()看下:
- get()方法:get方法是不用加锁的,是非阻塞的。
ConcurrentHashMap重点关注问题:
ConcurrentHashMap的特点概况,应用场景?
- 底层结构是散列表(数组+链表)+红黑树,这一点和HashMap是一样的。
- Hashtable是将所有的方法进行同步,效率低下。而ConcurrentHashMap作为一个高并发的容器,它是通过部分锁定+CAS算法来进行实现线程安全的。CAS算法也可以认为是乐观锁的一种。
- get方法是非阻塞,无锁的。重写Node类,通过volatile修饰next来实现每次获取都是最新设置的值,ConcurrentHashMap的key和Value都不能为null。
- ConcurrentHashMap的size方法是读取baseCount和CounterCell数据的总数量,因为是并发的也不一定正确。
- ConcurrentHashMap的弱一致性主要表现在他的一些视图和迭代器上,通过迭代器遍历元素的时候如果之前的元素发生修改是不会抛出fail-fast异常的,后面的元素如果修改了会体现在迭代器遍历的结果上。
ConcurrentHashMap扩容基本思路?
Hash表的扩容,一般都包含两个步骤:
-
table数组的扩容,一般就是新建一个2倍大小的槽数组,这个过程通过由一个单线程完成,且不允许出现并发。
-
数据迁移,就是把旧table中的各个槽中的结点重新分配到新table中。比如,单线程情况下,可以遍历原来的table,然后put到新table中。这一过程通常涉及到槽中key的rehash,因为key映射到桶的位置与table的大小有关,新table的大小变了,key映射的位置一般也会变化。
扩容时机问题;
其实ConcurrentHashMap的扩容时机和HashMap的扩容机制类似,并不是说链表长度大于8就一定扩容,而是对table数组的长度进行一次判断,如果table长度小于阈值MIN_TREEIFY_CAPACITY——默认64,则会调用tryPresize方法把数组长度扩大到原来的两倍。
ConcurrentHashMap的put流程?
来个总结版的,可以对照上面源码理解,这个比较重要些~
- 如果数据没初始化则初始化,通过hash方式(与hashMap的hash方式类似只不过将hash值转化为正数)找到要put节点在数组中的位置,如果该位置为空,则通过CAS的方式插入。
- 如果当前节点正在扩容则该线程参与扩容完成。
- 如果该位置有节点则通过synchronized加锁判断是如果该节点是链表则查找PUT,如果是红黑树则执行红黑树的PUT,之后通过bincount判断是否要将链表转化成红黑树。
- 最后更新size值并且判断是否需要扩容。
弱键WeakHashMap
在jvm中,一个对象如果不再被使用就会被当做垃圾给回收掉,判断一个对象是否是垃圾,通常有两种方法:引用计数法和可达性分析法。不管是哪一种方法判断一个对象是否是垃圾的条件总是一个对象的引用是都没有了。不太熟悉的点击直通车这里。 JDK.1.2 之后,Java 对引用的概念进行了扩充,将引用分为了:强引用、软引用、弱引用、虚引用4 种。而我们的WeakHashMap就是基于弱引用。
-
强引用:类似于 Object obj = new Object(); 创建的,只要强引用在垃圾回收器就不回收。
-
弱引用:用于描述一些还有用但非必需的对象。用SoftReference 类实现软引用,在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。若回收完还是没有足够的内存才会抛出内存溢出正常。
-
软引用:用来描述非必需对象的,强度比软引用更弱一些。用WeakReference 类实现弱引用,对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。
-
虚幻引用:幽灵引用或者幻影引用(很魔幻的名称)。它是一种最弱的引用关系,一个对象的虚引用不会对生存时间构成影响、也无法通过虚引用来取得对象实例。用PhantomReference 类实现虚引用,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
WeakHashMap定义:
public class WeakHashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V> { }
WeakHashMap简介:
- WeakHashMap,从名字可以看出它是一个 Map。它的使用上跟HashMap并没有什么区别。
- WeakHashMap 特殊之处在于 WeakHashMap 里的entry可能会被垃圾回收器自动删除,也就是说即使你没有调用remove()或者clear()方法,它的entry也可能会慢慢变少。所以多次调用比如isEmpty,containsKey,size等方法时可能会返回不同的结果。
- WeakHashMap中的key是间接保存在弱引用中的,所以当key没有被继续使用时,就可能会在GC的时候被回收掉。只有key对象是使用弱引用保存的,value对象实际上仍旧是通过普通的强引用来保持的,所以应该确保value不会直接或者间接的保持其对应key的强引用,因为这样会阻止key被回收。
- WeakHashMap中的数据结构是数组+链表的形式,这一点跟HashMap也是一致的,但不同的是,在JDK8中,当发生较多key冲突的时候,HashMap中会由链表转为红黑树,而WeakHashMap则一直使用链表进行存储。
一起来分析下WeakHashMap的一些问题:
-
WeakHashMap中的Entry为什么会自动被回收? 大家都知道HashMap实现里面有个Entry数组,WeakHashMap也一样也有一个Entry数组,但是此Entry与彼Entry有些不一样。WeakHashMap的Entry是继承WeakReference,这样一来,整个Entry就是一个WeakReference,再来看看Entry的构造方法,调用了super(key, queue),也就是调用了这个构造方法。
从这里我们可以看到其内部的Entry继承了WeakReference,也就是弱引用,所以就具有了弱引用的特点。不过还要注意一点,那就是ReferenceQueue,他的作用是GC会清理掉对象之后,引用对象会被放到ReferenceQueue中。
-
WeakHashMap中的Entry被GC后,WeakHashMap是如何将其移除的? WeakHashMap内部有一个expungeStaleEntries函数,在这个函数内部实现移除其内部不用的entry从而达到的自动释放内存的目的。因此我们每次访问WeakHashMap的时候,都会调用这个expungeStaleEntries函数清理一遍。首先GC每次清理掉一个对象之后,引用对象会被放到ReferenceQueue中。然后遍历这个queue进行删除即可。
-
WeakHashMap应用场景。 (1)由于WeakHashMap可以自动清除Entry,所以比较适合用于存储非必需对象,用作缓存非常合适。 (2)WeakHashMap还有这样一个不错的应用场景,配合事务进行使用,存储事务过程中的各类信息。可以使用如下结构:WeakHashMap<String,Map<K,V>> transactionCache;这里key为String类型,可以用来标志区分不同的事务,起到一个事务id的作用。value是一个map,可以是一个简单的HashMap或者LinkedHashMap,用来存放在事务中需要使用到的信息。在事务开始时创建一个事务id,并用它来作为key,事务结束后,将这个强引用消除掉,这样既能保证在事务中可以获取到所需要的信息,又能自动释放掉map中的所有信息。
作者寄语
本文介绍了Java集合中的Map知识,后面还有更多文章,欢迎大家阅读,本人见识有限,写的博客难免有错误或者疏忽的地方,还望各位大佬指点,感激不尽。
你知道的越多,你不知道的也越多。keep hungry keep foolish!
看完之后感觉如何,觉得不错的给小空个关注吧!小空会持续更新文章的~~
干货有质量,水文有情怀,微信搜索【程序控】,关注这个有趣的灵魂