Map
Map是一种用于快速查找的数据结构,它以键值对的形式存储数据,每一个键都是唯一的,且对应着一个值,如果想要查找Map中的数据,只需要传入一个键,Map会对键进行匹配并返回键所对应的值,可以说Map其实就是一个存放键值对的集合。Map被各种编程语言广泛使用,只不过在名称上可能会有些混淆,像Python中叫做字典(Dictionary),也有些语言称其为关联数组(Associative Array),但其实它们都是一样的,都是一个存放键值对的集合。至于Java中经常用到的HashMap也是Map的一种,它被称为散列表,关于散列表的细节我会在本文中解释HashMap的源码时提及。
Java还提供了一种与Map密切相关的数据结构:Set,它是数学意义上的集合,特性如下:
无序性:一个集合中,每个元素的地位都是相同的,元素之间也都是无序的。不过Java中也提供了有序的Set,这点倒是没有完全遵循。
互异性:一个集合中,任何两个元素都是不相同的。
确定性:给定一个集合以及其任一元素,该元素属于或者不属于该集合是必须可以确定的。
很明显,Map中的key就很符合这些特性,Set的实现其实就是在内部使用Map。例如,HashSet就定义了一个类型为HashMap的成员变量,向HashSet添加元素a,等同于向它内部的HashMap添加了一个key为a,value为一个Object对象的键值对,这个Object对象是HashSet的一个常量,它是一个虚拟值,没有什么实际含义,源码如下:
|
|
小插曲过后,让我们接着说Map,它是JDK的一个顶级接口,提供了三种集合视图(Collection Views):包含所有key的集合、包含所有value的集合以及包含所有键值对的集合,Map中的元素顺序与它所返回的集合视图中的元素的迭代顺序相关,也就是说,Map本身是不保证有序性的,当然也有例外,比如TreeMap就对有序性做出了保证,这主要因为它是基于红黑树实现的。
所谓的集合视图就是由集合本身提供的一种访问数据的方式,同时对视图的任何修改也会影响到集合。好比Map.keySet()
返回了它包含的key的集合,如果你调用了Map.remove(key)
那么keySet.contains(key)
也将返回false
,再比如说Arrays.asList(T)
可以把一个数组封装成一个List,这样你就可以通过List的API来访问和操作这些数据,如下列示例代码:
|
|
是不是感觉很神奇,其实Arrays.asList()
只是将传入的数组与Arrays
中的一个内部类ArrayList
(注意,它与java.util
包下的ArrayList
不是同一个)做了一个”绑定“,在调用get()
时会直接根据下标返回数组中的元素,而调用set()
时也会直接修改数组中对应下标的元素。相对于直接复制来说,集合视图的优点是内存利用率更高,假设你有一个数组,又很想使用List的API来操作它,那么你不用new一个ArrayList
以拷贝数组中的元素,只需要一点额外的内存(通过Arrays.ArrayList
对数组进行封装),原始数据依然是在数组中的,并不会复制成多份。
Map接口规范了Map数据结构的通用API(也含有几个用于简化操作的default方法,default是JDK8的新特性,它是接口中声明的方法的默认实现,即非抽象方法)并且还在内部定义了Entry接口(键值对的实体类),在JDK中提供的所有Map数据结构都实现了Map接口,下面为Map接口的源码(代码中的注释太长了,基本都是些实现的规范,为了篇幅我就尽量省略了)。
|
|
需要注意一点,这些default方法都是非线程安全的,任何保证线程安全的扩展类都必须重写这些方法,例如ConcurrentHashMap。
下图为Map的继承关系结构图,它也是本文接下来将要分析的Map实现类的大纲,这些实现类都是比较常用的,在JDK中Map的实现类有几十个,大部分都是我们用不到的,限于篇幅原因就不一一讲解了(本文包含许多源码与对实现细节的分析,建议读者抽出一段连续的空闲时间静下心来慢慢阅读)。
本文作者为SylvanasSun(sylvanas.sun@gmail.com),首发于SylvanasSun’s Blog。
原文链接:https://sylvanassun.github.io/2018/03/16/2018-03-16-map_family/
(转载请务必保留本段声明,并且保留超链接。)
AbstractMap
AbstractMap是一个抽象类,它是Map接口的一个骨架实现,最小化实现了此接口提供的抽象函数。在Java的Collection框架中基本都遵循了这一规定,骨架实现在接口与实现类之间构建了一层抽象,其目的是为了复用一些比较通用的函数以及方便扩展,例如List接口拥有骨架实现AbstractList、Set接口拥有骨架实现AbstractSet等。
下面我们按照不同的操作类型来看看AbstractMap都实现了什么,首先是查询操作:
|
|
可以发现这些操作都是依赖于函数entrySet()
的,它返回了一个键值对的集合视图,由于不同的实现子类的Entry实现可能也是不同的,所以一般是在内部实现一个继承于AbstractSet且泛型为Map.Entry
的内部类作为EntrySet,接下来是修改操作与批量操作:
|
|
AbstractMap并没有实现put()
函数,这样做是为了考虑到也许会有不可修改的Map实现子类继承它,而对于一个可修改的Map实现子类则必须重写put()
函数。
AbstractMap没有提供entrySet()
的实现,但是却提供了keySet()
与values()
集合视图的默认实现,它们都是依赖于entrySet()
返回的集合视图实现的,源码如下:
|
|
它还提供了两个Entry的实现类:SimpleEntry与SimpleImmutableEntry,这两个类的实现非常简单,区别也只是前者是可变的,而后者是不可变的。
|
|
我们通过阅读上述的源码不难发现,AbstractMap实现的操作都依赖于entrySet()
所返回的集合视图。剩下的函数就没什么好说的了,有兴趣的话可以自己去看看。
TreeMap
TreeMap是基于红黑树(一种自平衡的二叉查找树)实现的一个保证有序性的Map,在继承关系结构图中可以得知TreeMap实现了NavigableMap接口,而该接口又继承了SortedMap接口,我们先来看看这两个接口定义了一些什么功能。
SortedMap
首先是SortedMap接口,实现该接口的实现类应当按照自然排序保证key的有序性,所谓自然排序即是根据key的compareTo()
函数(需要实现Comparable接口)或者在构造函数中传入的Comparator实现类来进行排序,集合视图遍历元素的顺序也应当与key的顺序一致。SortedMap接口还定义了以下几个有效利用有序性的函数:
|
|
NavigableMap
然后是SortedMap的子接口NavigableMap,该接口扩展了一些用于导航(Navigation)的方法,像函数lowerEntry(key)
会根据传入的参数key返回一个小于key的最大的一对键值对,例如,我们如下调用lowerEntry(6)
,那么将返回key为5的键值对,如果没有key为5,则会返回key为4的键值对,以此类推,直到返回null(实在找不到的情况下)。
|
|
NavigableMap定义的都是一些类似于lowerEntry(key)
的方法和以逆序、升序排序的集合视图,这些方法利用有序性实现了相比SortedMap接口更加灵活的操作。
|
|
NavigableMap接口相对于SortedMap接口来说灵活了许多,正因为TreeMap也实现了该接口,所以在需要数据有序而且想灵活地访问它们的时候,使用TreeMap就非常合适了。
红黑树
上文我们提到TreeMap的内部实现基于红黑树,而红黑树又是二叉查找树的一种。二叉查找树是一种有序的树形结构,优势在于查找、插入的时间复杂度只有O(log n)
,特性如下:
任意节点最多含有两个子节点。
任意节点的左、右节点都可以看做为一棵二叉查找树。
如果任意节点的左子树不为空,那么左子树上的所有节点的值均小于它的根节点的值。
如果任意节点的右子树不为空,那么右子树上的所有节点的值均大于它的根节点的值。
任意节点的key都是不同的。
尽管二叉查找树看起来很美好,但事与愿违,二叉查找树在极端情况下会变得并不是那么有效率,假设我们有一个有序的整数序列:1,2,3,4,5,6,7,8,9,10,...
,如果把这个序列按顺序全部插入到二叉查找树时会发生什么呢?二叉查找树会产生倾斜,序列中的每一个元素都大于它的根节点(前一个元素),左子树永远是空的,那么这棵二叉查找树就跟一个普通的链表没什么区别了,查找操作的时间复杂度只有O(n)
。
为了解决这个问题需要引入自平衡的二叉查找树,所谓自平衡,即是在树结构将要倾斜的情况下进行修正,这个修正操作被称为旋转,通过旋转操作可以让树趋于平衡。
红黑树是平衡二叉查找树的一种实现,它的名字来自于它的子节点是着色的,每个子节点非黑即红,由于只有两种颜色(两种状态),一般使用boolean来表示,下面为TreeMap中实现的Entry,它代表红黑树中的一个节点:
|
|
任何平衡二叉查找树的查找操作都是与二叉查找树是一样的,因为查找操作并不会影响树的结构,也就不需要进行修正,代码如下:
|
|
而插入和删除操作与平衡二叉查找树的细节是息息相关的,关于红黑树的实现细节,我之前写过的一篇博客红黑树的那点事儿已经讲的很清楚了,对这方面不了解的读者建议去阅读一下,就不在这里重复叙述了。
集合视图
最后看一下TreeMap的集合视图的实现,集合视图一般都是实现了一个封装了当前实例的类,所以对集合视图的修改本质上就是在修改当前实例,TreeMap也不例外。
TreeMap的headMap()
、tailMap()
以及subMap()
函数都返回了一个静态内部类AscendingSubMap
|
|
一个局部视图最重要的是要能够判断出传入的key是否属于该视图的范围内,在上面的代码中可以发现NavigableSubMap提供了非常多的辅助函数用于判断范围,接下来我们看看NavigableSubMap的迭代器是如何实现的:
|
|
到目前为止,我们已经针对集合视图讨论了许多,想必大家也能够理解集合视图的概念了,由于SortedMap与NavigableMap的缘故,TreeMap中的集合视图是非常多的,包括各种局部视图和不同排序的视图,有兴趣的读者可以自己去看看源码,后面的内容不会再对集合视图进行过多的解释了。
HashMap
光从名字上应该也能猜到,HashMap肯定是基于hash算法实现的,这种基于hash实现的map叫做散列表(hash table)。
散列表中维护了一个数组,数组的每一个元素被称为一个桶(bucket),当你传入一个key = "a"
进行查询时,散列表会先把key传入散列(hash)函数中进行寻址,得到的结果就是数组的下标,然后再通过这个下标访问数组即可得到相关联的值。
我们都知道数组中数据的组织方式是线性的,它会直接分配一串连续的内存地址序列,要找到一个元素只需要根据下标来计算地址的偏移量即可(查找一个元素的起始地址为:数组的起始地址加上下标乘以该元素类型占用的地址大小)。因此散列表在理想的情况下,各种操作的时间复杂度只有O(1)
,这甚至超过了二叉查找树,虽然理想的情况并不总是满足的,关于这点之后我们还会提及。
为什么是hash?
hash算法是一种可以从任何数据中提取出其“指纹”的数据摘要算法,它将任意大小的数据(输入)映射到一个固定大小的序列(输出)上,这个序列被称为hash code、数据摘要或者指纹。比较出名的hash算法有MD5、SHA。
hash是具有唯一性且不可逆的,唯一性指的是相同的输入产生的hash code永远是一样的,而不可逆也比较容易理解,数据摘要算法并不是压缩算法,它只是生成了一个该数据的摘要,没有将数据进行压缩。压缩算法一般都是使用一种更节省空间的编码规则将数据重新编码,解压缩只需要按着编码规则解码就是了,试想一下,一个几百MB甚至几GB的数据生成的hash code都只是一个拥有固定长度的序列,如果再能逆向解压缩,那么其他压缩算法该情何以堪?
我们上述讨论的仅仅是在密码学中的hash算法,而在散列表中所需要的散列函数是要能够将key寻址到buckets中的一个位置,散列函数的实现影响到整个散列表的性能。
一个完美的散列函数要能够做到均匀地将key分布到buckets中,每一个key分配到一个bucket,但这是不可能的。虽然hash算法具有唯一性,但同时它还具有重复性,唯一性保证了相同输入的输出是一致的,却没有保证不同输入的输出是不一致的,也就是说,完全有可能两个不同的key被分配到了同一个bucket(因为它们的hash code可能是相同的),这叫做碰撞冲突。总之,理想很丰满,现实很骨感,散列函数只能尽可能地减少冲突,没有办法完全消除冲突。
散列函数的实现方法非常多,一个优秀的散列函数要看它能不能将key分布均匀。首先介绍一种最简单的方法:除留余数法,先对key进行hash得到它的hash code,然后再用该hash code对buckets数组的元素数量取余,得到的结果就是bucket的下标,这种方法简单高效,也可以当做对集群进行负载均衡的路由算法。
|
|
要注意一点,只有整数才能进行取余运算,如果hash code是一个字符串或别的类型,那么你需要将它转换为整数才能使用除留余数法,不过Java在Object对象中提供了hashCode()
函数,该函数返回了一个int值,所以任何你想要放入HashMap的自定义的抽象数据类型,都必须实现该函数和equals()
函数,这两个函数之间也遵守着一种约定:如果a.equals(b) == true
,那么a与b的hashCode()
也必须是相同的。
下面为String类的hashCode()
函数,它先遍历了内部的字符数组,然后在每一次循环中计算hash code(将hash code乘以一个素数并加上当前循环项的字符):
|
|
HashMap没有采用这么简单的方法,有一个原因是HashMap中的buckets数组的长度永远为一个2的幂,而不是一个素数,如果长度为素数,那么可能会更适合简单暴力的除留余数法(当然除留余数法虽然简单却并不是那么高效的),顺便一提,时代的眼泪Hashtable就使用了除留余数法,它没有强制约束buckets数组的长度。
HashMap在内部实现了一个hash()
函数,首先要对hashCode()
的返回值进行处理:
|
|
该函数将key.hashCode()
的低16位和高16位做了个异或运算,其目的是为了扰乱低位的信息以实现减少碰撞冲突。之后还需要把hash()
的返回值与table.length - 1
做与运算(table
为buckets数组),得到的结果即是数组的下标。
table.length - 1
就像是一个低位掩码(这个设计也优化了扩容操作的性能),它和hash()
做与操作时必然会将高位屏蔽(因为一个HashMap不可能有特别大的buckets数组,至少在不断自动扩容之前是不可能的,所以table.length - 1
的大部分高位都为0),只保留低位,看似没什么毛病,但这其实暗藏玄机,它会导致总是只有最低的几位是有效的,这样就算你的hashCode()
实现得再好也难以避免发生碰撞。这时,hash()
函数的价值就体现出来了,它对hash code的低位添加了随机性并且混合了高位的部分特征,显著减少了碰撞冲突的发生(关于hash()
函数的效果如何,可以参考这篇文章An introduction to optimising a hashing strategy)。
HashMap的散列函数具体流程如下图:
解决冲突
在上文中我们已经多次提到碰撞冲突,但是散列函数不可能是完美的,key分布完全均匀的情况是不存在的,所以碰撞冲突总是难以避免。
那么发生碰撞冲突时怎么办?总不能丢弃数据吧?必须要有一种合理的方法来解决这个问题,HashMap使用了叫做分离链接(Separate chaining,也有人翻译成拉链法)的策略来解决冲突。它的主要思想是每个bucket都应当是一个互相独立的数据结构,当发生冲突时,只需要把数据放入bucket中(因为bucket本身也是一个可以存放数据的数据结构),这样查询一个key所消耗的时间为访问bucket所消耗的时间加上在bucket中查找的时间。
HashMap的buckets数组其实就是一个链表数组,在发生冲突时只需要把Entry(还记得Entry吗?HashMap的Entry实现就是一个简单的链表节点,它包含了key和value以及hash code)放到链表的尾部,如果未发生冲突(位于该下标的bucket为null),那么就把该Entry做为链表的头部。而且HashMap还使用了Lazy策略,buckets数组只会在第一次调用put()
函数时进行初始化,这是一种防止内存浪费的做法,像ArrayList也是Lazy的,它在第一次调用add()
时才会初始化内部的数组。
不过链表虽然实现简单,但是在查找的效率上只有O(n)
,而且我们大部分的操作都是在进行查找,在hashCode()
设计的不是非常良好的情况下,碰撞冲突可能会频繁发生,链表也会变得越来越长,这个效率是非常差的。Java 8对其实现了优化,链表的节点数量在到达阈值时会转化为红黑树,这样查找所需的时间就只有O(log n)
了,阈值的定义如下:
|
|
如果在插入Entry时发现一条链表超过阈值,就会执行以下的操作,对该链表进行树化;相对的,如果在删除Entry(或进行扩容)时发现红黑树的节点太少(根据阈值UNTREEIFY_THRESHOLD),也会把红黑树退化成链表。
|
|
解决碰撞冲突的另一种策略叫做开放寻址法(Open addressing),它与分离链接法的思想截然不同。在开放寻址法中,所有Entry都会存储在buckets数组,一个明显的区别是,分离链接法中的每个bucket都是一个链表或其他的数据结构,而开放寻址法中的每个bucket就仅仅只是Entry本身。
开放寻址法是基于数组中的空位来解决冲突的,它的想法很简单,与其使用链表等数据结构,不如直接在数组中留出空位来当做一个标记,反正都要占用额外的内存。
当你查找一个key的时候,首先会从起始位置(通过散列函数计算出的数组索引)开始,不断检查当前bucket是否为目标Entry(通过比较key来判断),如果当前bucket不是目标Entry,那么就向后查找(查找的间隔取决于实现),直到碰见一个空位(null),这代表你想要找的key不存在。
如果你想要put一个全新的Entry(Map中没有这个key存在),依然会从起始位置开始进行查找,如果起始位置不是空的,则代表发生了碰撞冲突,只好不断向后查找,直到发现一个空位。
开放寻址法的名字也是来源于此,一个Entry的位置并不是完全由hash值决定的,所以也叫做Closed hashing,相对的,分离链接法也被称为Open hashing或Closed addressing。
根据向后探测(查找)的算法不同,开放寻址法有多种不同的实现,我们介绍一种最简单的算法:线性探测法(Linear probing),在发生碰撞时,简单地将索引加一,如果到达了数组的尾部就折回到数组的头部,直到找到目标或一个空位。
基于线性探测法的查找操作如下:
|
|
插入操作稍微麻烦一些,需要在插入之前判断当前数组的剩余容量,然后决定是否扩容。数组的剩余容量越多,代表Entry之间的间隔越大以及越早碰见空位(向后探测的次数就越少),效率自然就会变高。代价就是额外消耗的内存较多,这也是在用空间换取时间。
|
|
接下来是删除操作,需要注意一点,我们不能简单地把目标key所在的位置(keys和vals数组)设置为null,这样会导致此位置之后的Entry无法被探测到,所以需要将目标右侧的所有Entry重新插入到散列表中:
|
|
动态扩容
散列表以数组的形式组织bucket,问题在于数组是静态分配的,为了保证查找的性能,需要在Entry数量大于一个临界值时进行扩容,否则就算散列函数的效果再好,也难免产生碰撞。
所谓扩容,其实就是用一个容量更大(在原容量上乘以二)的数组来替换掉当前的数组,这个过程需要把旧数组中的数据重新hash到新数组,所以扩容也能在一定程度上减缓碰撞。
HashMap通过负载因子(Load Factor)乘以buckets数组的长度来计算出临界值,算法:threshold = load_factor * capacity
。比如,HashMap的默认初始容量为16(capacity = 16
),默认负载因子为0.75(load_factor = 0.75
),那么临界值就为threshold = 0.75 * 16 = 12
,只要Entry的数量大于12,就会触发扩容操作。
还可以通过下列的构造函数来自定义负载因子,负载因子越小查找的性能就会越高,但同时额外占用的内存就会越多,如果没有特殊需要不建议修改默认值。
|
|
buckets数组的大小约束对于整个HashMap都至关重要,为了防止传入一个不是2次幂的整数,必须要有所防范。tableSizeFor()
函数会尝试修正一个整数,并转换为离该整数最近的2次幂。
|
|
还记得数组索引的计算方法吗?index = (table.length - 1) & hash
,这其实是一种优化手段,由于数组的大小永远是一个2次幂,在扩容之后,一个元素的新索引要么是在原位置,要么就是在原位置加上扩容前的容量。这个方法的巧妙之处全在于&运算,之前提到过&运算只会关注n - 1(n = 数组长度)的有效位,当扩容之后,n的有效位相比之前会多增加一位(n会变成之前的二倍,所以确保数组长度永远是2次幂很重要),然后只需要判断hash在新增的有效位的位置是0还是1就可以算出新的索引位置,如果是0,那么索引没有发生变化,如果是1,索引就为原索引加上扩容前的容量。
这样在每次扩容时都不用重新计算hash,省去了不少时间,而且新增有效位是0还是1是带有随机性的,之前两个碰撞的Entry又有可能在扩容时再次均匀地散布开。下面是resize()
的源码:
|
|
使用HashMap时还需要注意一点,它不会动态地进行缩容,也就是说,你不应该保留一个已经删除过大量Entry的HashMap(如果不打算继续添加元素的话),此时它的buckets数组经过多次扩容已经变得非常大了,这会占用非常多的无用内存,这样做的好处是不用多次对数组进行扩容或缩容操作。不过一般也不会出现这种情况,如果遇见了,请毫不犹豫地丢掉它,或者把数据转移到一个新的HashMap。
添加元素
我们已经了解了HashMap的内部实现与工作原理,它在内部维护了一个数组,每一个key都会经过散列函数得出在数组的索引,如果两个key的索引相同,那么就使用分离链接法解决碰撞冲突,当Entry的数量大于临界值时,对数组进行扩容。
接下来以一个添加元素(put()
)的过程为例来梳理一下知识,下图是put()
函数的流程图:
然后是源码:
|
|
WeakHashMap
WeakHashMap是一个基于Map接口实现的散列表,实现细节与HashMap类似(都有负载因子、散列函数等等,但没有HashMap那么多优化手段),它的特殊之处在于每个key都是一个弱引用。
首先我们要明白什么是弱引用,Java将引用分为四类(从JDK1.2开始),强度依次逐渐减弱:
强引用: 就是平常使用的普通引用对象,例如
Object obj = new Object()
,这就是一个强引用,强引用只要还存在,就不会被垃圾收集器回收。软引用: 软引用表示一个还有用但并非必需的对象,不像强引用,它还需要通过SoftReference类来间接引用目标对象(除了强引用都是如此)。被软引用关联的对象,在将要发生内存溢出异常之前,会被放入回收范围之中以进行第二次回收(如果第二次回收之后依旧没有足够的内存,那么就会抛出OOM异常)。
弱引用: 同样是表示一个非必需的对象,但要比软引用的强度还要弱,需要通过WeakReference类来间接引用目标对象。被弱引用关联的对象只能存活到下一次垃圾回收发生之前,当触发垃圾回收时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象(如果这个对象还被强引用所引用,那么就不会被回收)。
虚引用: 这是一种最弱的引用关系,需要通过PhantomReference类来间接引用目标对象。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得对象实例。虚引用的唯一作用就是能在这个对象被回收时收到一个系统通知(结合ReferenceQueue使用)。基于这点可以通过虚引用来实现对象的析构函数,这比使用
finalize()
函数是要靠谱多了。
WeakHashMap适合用来当做一个缓存来使用。假设你的缓存系统是基于强引用实现的,那么你就必须以手动(或者用一条线程来不断轮询)的方式来删除一个无效的缓存项,而基于弱引用实现的缓存项只要没被其他强引用对象关联,就会被直接放入回收队列。
需要注意的是,只有key是被弱引用关联的,而value一般都是一个强引用对象。因此,需要确保value没有关联到它的key,否则会对key的回收产生阻碍。在极端的情况下,一个value对象A引用了另一个key对象D,而与D相对应的value对象C又反过来引用了与A相对应的key对象B,这就会产生一个引用循环,导致D与B都无法被正常回收。想要解决这个问题,就只能把value也变成一个弱引用,例如m.put(key, new WeakReference(value))
,弱引用之间的互相引用不会产生影响。
查找操作的实现跟HashMap相比简单了许多,只要读懂了HashMap,基本都能看懂,源码如下:
|
|
尽管key是一个弱引用,但仍需手动地回收那些已经无效的Entry。这个操作会在getTable()
函数中执行,不管是查找、添加还是删除,都需要调用getTable()
来获得buckets数组,所以这是种防止内存泄漏的被动保护措施。
|
|
然后是插入操作与删除操作,实现都比较简单:
|
|
我们并没有在put()
函数中发现key被转换成弱引用,这是怎么回事?key只有在第一次被放入buckets数组时才需要转换成弱引用,也就是new Entry<>(k, value, queue, h, e)
,WeakHashMap的Entry实现其实就是WeakReference的子类。
|
|
有关使用WeakReference的一个典型案例是ThreadLocal,感兴趣的读者可以参考我之前写的博客聊一聊Spring中的线程安全性。
LinkedHashMap
LinkedHashMap继承HashMap并实现了Map接口,同时具有可预测的迭代顺序(按照插入顺序排序)。它与HashMap的不同之处在于,维护了一条贯穿其全部Entry的双向链表(因为额外维护了链表的关系,性能上要略差于HashMap,不过集合视图的遍历时间与元素数量成正比,而HashMap是与buckets数组的长度成正比的),可以认为它是散列表与链表的结合。
|
|
LinkedHashMap的Entry实现也继承自HashMap,只不过多了指向前后的两个指针。
|
|
你也可以通过构造函数来构造一个迭代顺序为访问顺序(accessOrder设为true)的LinkedHashMap,这个访问顺序指的是按照最近被访问的Entry的顺序进行排序(从最近最少访问到最近最多访问)。基于这点可以简单实现一个采用LRU(Least Recently Used)策略的缓存。
|
|
LinkedHashMap复用了HashMap的大部分代码,所以它的查找实现是非常简单的,唯一稍微复杂点的操作是保证访问顺序。
|
|
还记得这些afterNodeXXXX命名格式的函数吗?我们之前已经在HashMap中见识过了,这些函数在HashMap中只是一个空实现,是专门用来让LinkedHashMap重写实现的hook函数。
|
|
注意removeEldestEntry()
默认永远返回false,这时它的行为与普通的Map无异。如果你把removeEldestEntry()
重写为永远返回true,那么就有可能使LinkedHashMap处于一个永远为空的状态(每次put()
或者putAll()
都会删除头节点)。
一个比较合理的实现示例:
|
|
LinkedHashMap重写了newNode()
等函数,以初始化或连接节点到它内部的双向链表:
|
|
遍历LinkedHashMap所需要的时间与Entry数量成正比,这是因为迭代器直接对双向链表进行迭代,而链表中只会含有Entry节点。迭代的顺序是从头节点开始一直到尾节点,插入操作会将新节点链接到尾部,所以保证了插入顺序,而访问顺序会通过afterNodeAccess()
来保证,访问次数越多的节点越接近尾部。
|
|
ConcurrentHashMap
我们上述所讲的Map都是非线程安全的,这意味着不应该在多个线程中对这些Map进行修改操作,轻则会产生数据不一致的问题,甚至还会因为并发插入元素而导致链表成环(插入会触发扩容,而扩容操作需要将原数组中的元素rehash到新数组,这时并发操作就有可能产生链表的循环引用从而成环),这样在查找时就会发生死循环,影响到整个应用程序。
Collections.synchronizedMap(Map<K,V> m)
可以将一个Map转换成线程安全的实现,其实也就是通过一个包装类,然后把所有功能都委托给传入的Map实现,而且包装类是基于synchronized
关键字来保证线程安全的(时代的眼泪Hashtable也是基于synchronized
关键字),底层使用的是互斥锁(同一时间内只能由持有锁的线程访问,其他竞争线程进入睡眠状态),性能与吞吐量差强人意。
|
|
然而ConcurrentHashMap的实现细节远没有这么简单,因此性能也要高上许多。它没有使用一个全局锁来锁住自己,而是采用了减少锁粒度的方法,尽量减少因为竞争锁而导致的阻塞与冲突,而且ConcurrentHashMap的检索操作是不需要锁的。
在Java 7中,ConcurrentHashMap把内部细分成了若干个小的HashMap,称之为段(Segment),默认被分为16个段。对于一个写操作而言,会先根据hash code进行寻址,得出该Entry应被存放在哪一个Segment,然后只要对该Segment加锁即可。
理想情况下,一个默认的ConcurrentHashMap可以同时接受16个线程进行写操作(如果都是对不同Segment进行操作的话)。
分段锁对于size()
这样的全局操作来说就没有任何作用了,想要得出Entry的数量就需要遍历所有Segment,获得所有的锁,然后再统计总数。事实上,ConcurrentHashMap会先试图使用无锁的方式统计总数,这个尝试会进行3次,如果在相邻的2次计算中获得的Segment的modCount次数一致,代表这两次计算过程中都没有发生过修改操作,那么就可以当做最终结果返回,否则,就要获得所有Segment的锁,重新计算size。
本文主要讨论的是Java 8的ConcurrentHashMap,它与Java 7的实现差别较大。完全放弃了段的设计,而是变回与HashMap相似的设计,使用buckets数组与分离链接法(同样会在超过阈值时树化,对于构造红黑树的逻辑与HashMap差别不大,只不过需要额外使用CAS来保证线程安全),锁的粒度也被细分到每个数组元素(个人认为这样做的原因是因为HashMap在Java 8中也实现了不少优化,即使碰撞严重,也能保证一定的性能,而且Segment不仅臃肿还有弱一致性的问题存在),所以它的并发级别与数组长度相关(Java 7则是与段数相关)。
|
|
寻址
ConcurrentHashMap的散列函数与HashMap并没有什么区别,同样是把key的hash code的高16位与低16位进行异或运算(因为ConcurrentHashMap的buckets数组长度也永远是一个2的N次方),然后将扰乱后的hash code与数组的长度减一(实际可访问到的最大索引)进行与运算,得出的结果即是目标所在的位置。
|
|
下面是查找操作的源码,实现比较简单。
|
|
一个普通的节点(链表节点)的hash不可能小于0(已经在spread()
函数中修正过了),所以小于0的只可能是一个特殊节点,它不能用while循环中遍历链表的方式来进行遍历。
TreeBin是红黑树的头部节点(红黑树的节点为TreeNode),它本身不含有key与value,而是指向一个TreeNode节点的链表与它们的根节点,同时使用CAS(ConcurrentHashMap并不是完全基于互斥锁实现的,而是与CAS这种乐观策略搭配使用,以提高性能)实现了一个读写锁,迫使Writer(持有这个锁)在树重构操作之前等待Reader完成。
ForwardingNode是一个在数据转移过程(由扩容引起)中使用的临时节点,它会被插入到头部。它与TreeBin(和TreeNode)都是Node类的子类。
为了判断出哪些是特殊节点,TreeBin和ForwardingNode的hash域都只是一个虚拟值:
|
|
可见性
我们在get()
函数中并没有发现任何与锁相关的代码,那么它是怎么保证线程安全的呢?一个操作ConcurrentHashMap.get("a")
,它的步骤基本分为以下几步:
根据散列函数计算出的索引访问table。
从table中取出头节点。
遍历头节点直到找到目标节点。
从目标节点中取出value并返回。
所以只要保证访问table与节点的操作总是能够返回最新的数据就可以了。ConcurrentHashMap并没有采用锁的方式,而是通过volatile
关键字来保证它们的可见性。在上文贴出的代码中可以发现,table、Node.val和Node.next都是被volatile
关键字所修饰的。
volatile
关键字保证了多线程环境下变量的可见性与有序性,底层实现基于内存屏障(Memory Barrier)。
为了优化性能,现代CPU工作时的指令执行顺序与应用程序的代码顺序其实是不一致的(有些编译器也会进行这种优化),也就是所谓的乱序执行技术。乱序执行可以提高CPU流水线的工作效率,只要保证数据符合程序逻辑上的正确性即可(遵循happens-before
原则)。不过如今是多核时代,如果随便乱序而不提供防护措施那是会出问题的。每一个cpu上都会进行乱序优化,单cpu所保证的逻辑次序可能会被其他cpu所破坏。
内存屏障就是针对此情况的防护措施。可以认为它是一个同步点(但它本身也是一条cpu指令)。例如在IA32
指令集架构中引入的SFENCE
指令,在该指令之前的所有写操作必须全部完成,读操作仍可以乱序执行。LFENCE
指令则保证之前的所有读操作必须全部完成,另外还有粒度更粗的MFENCE
指令保证之前的所有读写操作都必须全部完成。
内存屏障就像是一个保护指令顺序的栅栏,保护后面的指令不被前面的指令跨越。将内存屏障插入到写操作与读操作之间,就可以保证之后的读操作可以访问到最新的数据,因为屏障前的写操作已经把数据写回到内存(根据缓存一致性协议,不会直接写回到内存,而是改变该cpu私有缓存中的状态,然后通知给其他cpu这个缓存行已经被修改过了,之后另一个cpu在读操作时就可以发现该缓存行已经是无效的了,这时它会从其他cpu中读取最新的缓存行,然后之前的cpu才会更改状态并写回到内存)。
例如,读一个被volatile
修饰的变量V总是能够从JMM(Java Memory Model)主内存中获得最新的数据。因为内存屏障的原因,每次在使用变量V(通过JVM指令use
,后面说的也都是JVM中的指令而不是cpu)之前都必须先执行load
指令(把从主内存中得到的数据放入到工作内存),根据JVM的规定,load
指令必须发生在read
指令(从主内存中读取数据)之后,所以每次访问变量V都会先从主内存中读取。相对的,写操作也因为内存屏障保证的指令顺序,每次都会直接写回到主内存。
不过volatile
关键字并不能保证操作的原子性,对该变量进行并发的连续操作是非线程安全的,所幸ConcurrentHashMap只是用来确保访问到的变量是最新的,所以也不会发生什么问题。
出于性能考虑,Doug Lea(java.util.concurrent
包的作者)直接通过Unsafe类来对table进行操作。
Java号称是安全的编程语言,而保证安全的代价就是牺牲程序员自由操控内存的能力。像在C/C++中可以通过操作指针变量达到操作内存的目的(其实操作的是虚拟地址),但这种灵活性在新手手中也经常会带来一些愚蠢的错误,比如内存访问越界。
Unsafe从字面意思可以看出是不安全的,它包含了许多本地方法(在JVM平台上运行的其他语言编写的程序,主要为C/C++,由JNI
实现),这些方法支持了对指针的操作,所以它才被称为是不安全的。虽然不安全,但毕竟是由C/C++实现的,像一些与操作系统交互的操作肯定是快过Java的,毕竟Java与操作系统之间还隔了一层抽象(JVM),不过代价就是失去了JVM所带来的多平台可移植性(本质上也只是一个c/cpp文件,如果换了平台那就要重新编译)。
对table进行操作的函数有以下三个,都使用到了Unsafe(在java.util.concurrent
包随处可见):
|
|
如果对Unsafe感兴趣,可以参考这篇文章:Java Magic. Part 4: sun.misc.Unsafe
初始化
ConcurrentHashMap与HashMap一样是Lazy的,buckets数组会在第一次访问put()
函数时进行初始化,它的默认构造函数甚至是个空函数。
|
|
但是有一点需要注意,ConcurrentHashMap是工作在多线程并发环境下的,如果有多个线程同时调用了put()
函数该怎么办?这会导致重复初始化,所以必须要有对应的防护措施。
ConcurrentHashMap声明了一个用于控制table的初始化与扩容的实例变量sizeCtl,默认值为0。当它是一个负数的时候,代表table正处于初始化或者扩容的状态。-1
表示table正在进行初始化,-N
则表示当前有N-1个线程正在进行扩容。
在其他情况下,如果table还未初始化(table == null
),sizeCtl表示table进行初始化的数组大小(所以从构造函数传入的initialCapacity在经过计算后会被赋给它)。如果table已经初始化过了,则表示下次触发扩容操作的阈值,算法stzeCtl = n - (n >>> 2)
,也就是n的75%,与默认负载因子(0.75)的HashMap一致。
|
|
初始化table的操作位于函数initTable()
,源码如下:
|
|
sizeCtl是一个volatile
变量,只要有一个线程CAS操作成功,sizeCtl就会被暂时地修改为-1,这样其他线程就能够根据sizeCtl得知table是否已经处于初始化状态中,最后sizeCtl会被设置成阈值,用于触发扩容操作。
扩容
ConcurrentHashMap触发扩容的时机与HashMap类似,要么是在将链表转换成红黑树时判断table数组的长度是否小于阈值(64),如果小于就进行扩容而不是树化,要么就是在添加元素的时候,判断当前Entry数量是否超过阈值,如果超过就进行扩容。
|
|
可以看到有关sizeCtl的操作牵涉到了大量的位运算,我们先来理解这些位运算的意义。首先是resizeStamp()
,该函数返回一个用于数据校验的标志位,意思是对长度为n的table进行扩容。它将n的前导零(最高有效位之前的零的数量)和1 << 15
做或运算,这时低16位的最高位为1,其他都为n的前导零。
|
|
初始化sizeCtl(扩容操作被第一个线程首次进行)的算法为(rs << RESIZE_STAMP_SHIFT) + 2
,首先RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS = 16
,那么rs << 16
等于将这个标志位移动到了高16位,这时最高位为1,所以sizeCtl此时是个负数,然后加二(至于为什么是2,还记得有关sizeCtl的说明吗?1代表初始化状态,所以实际的线程个数是要减去1的)代表当前有一个线程正在进行扩容,
这样sizeCtl就被分割成了两部分,高16位是一个对n的数据校验的标志位,低16位表示参与扩容操作的线程个数 + 1。
可能会有读者有所疑惑,更新进行扩容的线程数量的操作为什么是sc + 1
而不是sc - 1
,这是因为对sizeCtl的操作都是基于位运算的,所以不会关心它本身的数值是多少,只关心它在二进制上的数值,而sc + 1
会在低16位上加1。
tryPresize()
函数跟addCount()
的后半段逻辑类似,不断地根据sizeCtl判断当前的状态,然后选择对应的策略。
|
|
扩容操作的核心在于数据的转移,在单线程环境下数据的转移很简单,无非就是把旧数组中的数据迁移到新的数组。但是这在多线程环境下是行不通的,需要保证线程安全性,在扩容的时候其他线程也可能正在添加元素,这时又触发了扩容怎么办?有人可能会说,这不难啊,用一个互斥锁把数据转移操作的过程锁住不就好了?这确实是一种可行的解决方法,但同样也会带来极差的吞吐量。
互斥锁会导致所有访问临界区的线程陷入阻塞状态,这会消耗额外的系统资源,内核需要保存这些线程的上下文并放到阻塞队列,持有锁的线程耗时越长,其他竞争线程就会一直被阻塞,因此吞吐量低下,导致响应时间缓慢。而且锁总是会伴随着死锁问题,一旦发生死锁,整个应用程序都会因此受到影响,所以加锁永远是最后的备选方案。
Doug Lea没有选择直接加锁,而是基于CAS实现无锁的并发同步策略,令人佩服的是他不仅没有把其他线程拒之门外,甚至还邀请它们一起来协助工作。
那么如何才能让多个线程协同工作呢?Doug Lea把整个table数组当做多个线程之间共享的任务队列,然后只需维护一个指针,当有一个线程开始进行数据转移,就会先移动指针,表示指针划过的这片bucket区域由该线程负责。
这个指针被声明为一个volatile
整型变量,它的初始位置位于table的尾部,即它等于table.length
,很明显这个任务队列是逆向遍历的。
|
|
一个已经迁移完毕的bucket会被替换成ForwardingNode节点,用来标记此bucket已经被其他线程迁移完毕了。我们之前提到过ForwardingNode,它是一个特殊节点,可以通过hash域的虚拟值来识别它,它同样重写了find()
函数,用来在新数组中查找目标。
数据迁移的操作位于transfer()
函数,多个线程之间依靠sizeCtl与transferIndex指针来协同工作,每个线程都有自己负责的区域,一个完成迁移的bucket会被设置为ForwardingNode,其他线程遇见这个特殊节点就跳过该bucket,处理下一个bucket。
transfer()
函数可以大致分为三部分,第一部分对后续需要使用的变量进行初始化:
|
|
第二部分为当前线程分配任务和控制当前线程的任务进度,这部分是transfer()
的核心逻辑,描述了如何与其他线程协同工作:
|
|
最后一部分是具体的迁移过程(对当前指向的bucket),这部分的逻辑与HashMap类似,拿旧数组的容量当做一个掩码,然后与节点的hash进行与操作,可以得出该节点的新增有效位,如果新增有效位为0就放入一个链表A,如果为1就放入另一个链表B,链表A在新数组中的位置不变(跟在旧数组的索引一致),链表B在新数组中的位置为原索引加上旧数组容量。
这个方法减少了rehash的计算量,而且还能达到均匀分布的目的,如果不能理解请去看本文中HashMap扩容操作的解释。
|
|
计数
在Java 7中ConcurrentHashMap对每个Segment单独计数,想要得到总数就需要获得所有Segment的锁,然后进行统计。由于Java 8抛弃了Segment,显然是不能再这样做了,而且这种方法虽然简单准确但也舍弃了性能。
Java 8声明了一个volatile
变量baseCount用于记录元素的个数,对这个变量的修改操作是基于CAS的,每当插入元素或删除元素时都会调用addCount()
函数进行计数。
|
|
counterCells是一个元素为CounterCell的数组,该数组的大小与当前机器的CPU数量有关,并且它不会被主动初始化,只有在调用fullAddCount()
函数时才会进行初始化。
CounterCell是一个简单的内部静态类,每个CounterCell都是一个用于记录数量的单元:
|
|
注解@sun.misc.Contended
用于解决伪共享问题。所谓伪共享,即是在同一缓存行(CPU缓存的基本单位)中存储了多个变量,当其中一个变量被修改时,就会影响到同一缓存行内的其他变量,导致它们也要跟着被标记为失效,其他变量的缓存命中率将会受到影响。解决伪共享问题的方法一般是对该变量填充一些无意义的占位数据,从而使它独享一个缓存行。
ConcurrentHashMap的计数设计与LongAdder类似。在一个低并发的情况下,就只是简单地使用CAS操作来对baseCount进行更新,但只要这个CAS操作失败一次,就代表有多个线程正在竞争,那么就转而使用CounterCell数组进行计数,数组内的每个ConuterCell都是一个独立的计数单元。
每个线程都会通过ThreadLocalRandom.getProbe() & m
寻址找到属于它的CounterCell,然后进行计数。ThreadLocalRandom是一个线程私有的伪随机数生成器,每个线程的probe都是不同的(这点基于ThreadLocalRandom的内部实现,它在内部维护了一个probeGenerator,这是一个类型为AtomicInteger的静态常量,每当初始化一个ThreadLocalRandom时probeGenerator都会先自增一个常量然后返回的整数即为当前线程的probe,probe变量被维护在Thread对象中),可以认为每个线程的probe就是它在CounterCell数组中的hash code。
这种方法将竞争数据按照线程的粒度进行分离,相比所有竞争线程对一个共享变量使用CAS不断尝试在性能上要效率多了,这也是为什么在高并发环境下LongAdder要优于AtomicInteger的原因。
fullAddCount()
函数根据当前线程的probe寻找对应的CounterCell进行计数,如果CounterCell数组未被初始化,则初始化CounterCell数组和CounterCell。该函数的实现与Striped64类(LongAdder的父类)的longAccumulate()
函数是一样的,把CounterCell数组当成一个散列表,每个线程的probe就是hash code,散列函数也仅仅是简单的(n - 1) & probe
。
CounterCell数组的大小永远是一个2的n次方,初始容量为2,每次扩容的新容量都是之前容量乘以二,处于性能考虑,它的最大容量上限是机器的CPU数量。
所以说CounterCell数组的碰撞冲突是很严重的,因为它的bucket基数太小了。而发生碰撞就代表着一个CounterCell会被多个线程竞争,为了解决这个问题,Doug Lea使用无限循环加上CAS来模拟出一个自旋锁来保证线程安全,自旋锁的实现基于一个被volatile
修饰的整数变量,该变量只会有两种状态:0和1,当它被设置为0时表示没有加锁,当它被设置为1时表示已被其他线程加锁。这个自旋锁用于保护初始化CounterCell、初始化CounterCell数组以及对CounterCell数组进行扩容时的安全。
CounterCell更新计数是依赖于CAS的,每次循环都会尝试通过CAS进行更新,如果成功就退出无限循环,否则就调用ThreadLocalRandom.advanceProbe()
函数为当前线程更新probe,然后重新开始循环,以期望下一次寻址到的CounterCell没有被其他线程竞争。
如果连着两次CAS更新都没有成功,那么会对CounterCell数组进行一次扩容,这个扩容操作只会在当前循环中触发一次,而且只能在容量小于上限时触发。
fullAddCount()
函数的主要流程如下:
首先检查当前线程有没有初始化过ThreadLocalRandom,如果没有则进行初始化。ThreadLocalRandom负责更新线程的probe,而probe又是在数组中进行寻址的关键。
检查CounterCell数组是否已经初始化,如果已初始化,那么就根据probe找到对应的CounterCell。
如果这个CounterCell等于null,需要先初始化CounterCell,通过把计数增量传入构造函数,所以初始化只要成功就说明更新计数已经完成了。初始化的过程需要获取自旋锁。
如果不为null,就按上文所说的逻辑对CounterCell实施更新计数。
CounterCell数组未被初始化,尝试获取自旋锁,进行初始化。数组初始化的过程会附带初始化一个CounterCell来记录计数增量,所以只要初始化成功就表示更新计数完成。
如果自旋锁被其他线程占用,无法进行数组的初始化,只好通过CAS更新baseCount。
|
|
对于统计总数,只要能够理解CounterCell的思想,就很简单了。仔细想一想,每次计数的更新都会被分摊在baseCount和CounterCell数组中的某一CounterCell,想要获得总数,把它们统计相加就是了。
|
|
其实size()
函数返回的总数可能并不是百分百精确的,试想如果前一个遍历过的CounterCell又进行了更新会怎么样?尽管只是一个估算值,但在大多数场景下都还能接受,而且性能上是要比Java 7好上太多了。
添加元素
添加元素的主要逻辑与HashMap没什么区别,有所区别的复杂操作如扩容和计数我们上文都已经深入解析过了,所以整体来说putVal()
函数还是比较简单的,可能唯一需要注意的就是在对节点进行操作的时候需要通过互斥锁保证线程安全,这个互斥锁的粒度很小,只对需要操作的这个bucket加锁。
|
|
至于删除元素的操作位于函数replaceNode(Object key, V value, Object cv)
,当table[key].val
等于期望值cv时(或cv等于null),更新节点的值为value,如果value等于null,那么删除该节点。
remove()
函数通过调用replaceNode(key, null, null)
来达成删除目标节点的目的,replaceNode()
的具体实现与putVal()
没什么差别,只不过对链表的操作有所不同而已,所以就不多叙述了。
并行计算
Java 8除了对ConcurrentHashMap重新设计以外,还引入了基于Lambda表达式的Stream API。它是对集合对象功能上的增强(所以不止ConcurrentHashMap,其他集合也都实现了该API),以一种优雅的方式来批量操作、聚合或遍历集合中的数据。
最重要的是,它还提供了并行模式,充分利用了多核CPU的优势实现并行计算。让我们看看如下的示例代码:
|
|
这段代码通过两个线程(包括主线程)并行地遍历map中的元素,然后输出到控制台,输出如下:
|
|
很明显,有两个线程在进行工作,那么这是怎么实现的呢?我们先来看看forEach()
函数:
|
|
parallelismThreshold
是需要并行执行该操作的线程数量,action
则是回调函数(我们想要执行的操作)。action
的类型为BiConsumer,是一个用于支持Lambda表达式的FunctionalInterface,它接受两个输入参数并返回0个结果。
|
|
看来实现并行计算的关键在于ForEachMappingTask对象,通过它的继承关系结构图可以发现,ForEachMappingTask其实就是ForkJoinTask。
集合的并行计算是基于Fork/Join框架实现的,工作线程交由ForkJoinPool线程池维护。它推崇分而治之的思想,将一个大的任务分解成多个小的任务,通过fork()
函数(有点像Linux的fork()
系统调用来创建子进程)来开启一个工作线程执行其中一个小任务,通过join()
函数等待工作线程执行完毕(需要等所有工作线程执行完毕才能合并最终结果),只要所有的小任务都已经处理完成,就代表这个大的任务也完成了。
像上文中的示例代码就是将遍历这个大任务分解成了N个小任务,然后交由两个工作线程进行处理。
|
|
其他并行计算函数的实现也都差不多,只不过具体的Task实现不同,例如search()
:
|
|
为了节省篇幅(说实话现在似乎很少有人能耐心看完一篇长文(:з」∠)),有关Stream API是如何使用Fork/Join框架进行工作以及实现细节就不多讲了,以后有机会再说吧。