HASHMAP扩展
之前读Effective Java的时候稍微总结过HashMap: http://www.jianshu.com/p/24c10ce29c85 写这篇的原因:
- 看了小灰的关于HashMap的漫画,就想要再整理一下关于HashMap的一些知识。
- 2017.11.29,我们的项目里用到了LRUCache,正好LeetCode也有这题,我就去做了下,先后思考了很多种数据结构,最后我想到了能不能用LinkedHashMap这样有序的Map。所以请了解了下LinkedHashMap。
0x00 散列方法和解决HASH冲突的方法
之前考研复习数据结构的时候背过各种名词,也画过、算过各种解决hash冲突的图和公式,但是当时其实并不理解HashMap,毕竟当时根本没什么开发经验。所以现在有点混淆了,就整理下各种名词: ####散列的方法 散列的方法,也就是把key的hashcode分配到table的各个index的slot上的方法有:
(1)直接定址法:比如在一个0~100岁的年龄统计表,我们就可以把年龄作为地址。 (2)平方取中法 (3)除留余数法 取关键字被某个不大于哈希表表长m的数p除后所得余数为哈希地址。该方法的关键是选取p。选取的p应使得散列函数值尽可能与关键字的各位相关。p最好为素数。 (4)随机数法 (5)取模法
Java的HashMap里用的就是我们众所周知的&方法,类似(5),但效率更高:
index = HashCode(Key) & (Length - 1)
解决冲突的:
就是由于key的hashCode相同或者hashCode散列出来的index相同,需要放在一个bucket里的方法,也就是怎么在桶里排序。
(1) 拉链法 这个印象最深了,拉链法就是Java的HashMap里用到的方法,也就是「头插法」,后加上的元素,放在首位,因为设计者认为后加上的元素被访问的概率更大,所以这样遍历链表效率高。我原本以为这样put是一个O(1)操作,但其实也不是,因为你首先要确定这个bucket里是否有个key的hash,如果有,就要覆盖掉这个hash对应的value。我常常会理解错,认为bucket里存放的是value,这是不对的,bucket里存放的是key的hash,value只是hash的附属品,看下下面的图就明白了:
(2)开放定址法 不知道这个名字代表的啥了,我印象里反正就是把hash先定位到一个slot(那显然就不是链表,而是数组了),然后往左或者往右挪,直到找到一个slot坑位。0x01 PUT要点
简单说下PUT过程:
对key的hashCode()做hash,然后再计算index; 如果没碰撞直接放到bucket里; 如果碰撞了,以链表的形式存在buckets后; 如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树; 如果节点已经存在就替换old value(保证key的唯一性) 如果bucket满了(超过load factor*current capacity),就要resize。
注意几个点吧,
- hash时用的一般是取模法,发生collision(hash冲突,碰撞)的时候用拉链法之类的解决hash冲突。
- hash碰撞导致一个bucket的链表长度大于TREEIFY_THRESHOLD的时候就转换成红黑树,put/get一次的复杂度变为O(logn)
- 如果默认的capacity是16,capacity是0.75,那么当12个桶里都有元素的时候,就会自动resize,这样你在一个桶里找东西变得容易了呀。
提到的一些细节:
-
get的时候,后插入的节点会在Entry的前面(头插法)。因为HashMap的发明者认为,后插入的Entry被查找的可能性更大。注意,因为Entry是链表,所以是有序的,这个跟bucket的排列不同。
-
HashMap初始长度是16(1111B)。长度之所以是2的幂是因为,HashMap的Hash并不是用mod(length -1)完成的,而是用 & (length - 1)完成的,众所周知&效率比%高。而用&的话,当然低位都是1最好了,这样Hash Collision的几率最小。
0x02 高并发下的HashMap产生的循环链表
-
Hashmap在插入元素过多的时候需要进行Resize,Resize的条件是 HashMap.Size >= Capacity * LoadFactor。
-
Hashmap的Resize包含扩容和ReHash两个步骤,ReHash在并发的情况下可能会形成链表环。
这样Resize之后get一个不存在的key的时候就会陷入死循环:
0x03 ConcurrentHashMap解决HashMap的线程安全问题
用HashTable或者Collections.synchronizedMap都是线程安全的(HashTable与HashMap的另一个区别是HashTable不允许空的key-value)。但是他们都是对整个集合加锁,导致同时间的其他操作阻塞(不能同事put和get了,我们知道HashMap之所以不安全,是因为put的时候rehash的时候产生的循环链表)。
ConCurrentHashMap用了Segment,每个Segment读写自治,Segment间不影响。我们知道HashMap线程不安全是产生在并发写入的时候,那 Case1:不同Segment的并发写入 Case2:同一Segment的一写一读 这两个Case本来就是没问题的;只有在 同一Segment并发put才会上锁:
concurrenthashmap中大量运用了volatile。另外要注意,小灰的几个漫画都是基于Java1.7,在1.8中,上面提到,hash碰撞导致一个bucket的链表长度大于TREEIFY_THRESHOLD的时候就转换成红黑树,put/get一次的复杂度变为O(logn)。
0x04 LinkedHashMap与LruCache
首先,LinkedHashMap
为什么能实现顺序存储Entry,我们知道HashMap是用了& 2的幂的Hash算法把key的hash hash到table(数组)上去,所以它的bucket(Entry)的顺序是乱的,LinkedHashMap
总不能为了保持顺序就不去hash吧,那样就不是HashMap了,Hash本身就是为了解决冲突->快速访问存在的,如果顺序存储,那就成了ArrayList之类的结构了。 想到这儿,我又开了脑洞,为什么HashMap做不到像ArrayList那样一个bucket只存一个数据? 为什么ArrayList的查找只能是遍历,不能像Map一样是O(1)? 每当有这个疑问的时候,就看下这个Hash公式吧:
index = HashCode(Key) & (Length - 1)
- 之所以可以准确定位,是因为bucket存放的,是key的hash经过与操作后得到的index,存是这么存,取也根据这个取,所以是O(1);
- 之所以不能一个bucket存放一个数据,是因为Hash算法总会发生冲突,有两种可能 (1) 不同的key,hashCode相同 (2)不同的hashCode,与操作得到的index相同,比如默认Capacity16,1011010101110 1001 & 1111得到index = 1001B = 9,只是判断最后4bit的,自然会有冲突。
LruCache的具体实现,移步。