散列表
散列表
概要
散列表也叫哈希表(hash table),是存储Key-Value映射的集合。对于某一个Key,散列表可以在接近O(1)的时间内进行读写操作。 散列表在本质上也是一个数组,可以根据下标,进行元素的随机访问。
下面这个table就是散列表:
一、散列表的一些基本概念
1. 哈希函数
散列表通过哈希函数实现Key和数组下标的转换,每个键值对都会通过哈希函数计算出一个索引,然后存储在对应的位置上。
2. 哈希冲突
不同的 key 通过哈希函数获得的下标可能是相同的,这种情况就叫做哈希冲突。通过开放寻址法和链表法来解决哈希冲突 。
详细请参考文章《散列冲突(哈希碰撞)的解决办法》
二、Java中的hashMap
HashMap 是 Java 中的一种数据结构。它实际上是基于散列表(hash table)实现的。
HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。 数组中的每一个Entry元素,又是一个链表的头节点。HashMap数组每一个元素的初始值都是Null。
代码示例:
1 public static void main(String[] args) { 2 HashMap<String, Integer> numbers = new HashMap<>(16); 3 numbers.put("one", 1); 4 numbers.put("two", 2); 5 numbers.put("Three", 3); 6 System.out.println("HashMap: " + numbers); 7 8 // entrySet() 方法可以与 for-each 循环一起使用,用来遍历迭代 HashMap 中每一个映射项 9 for (Map.Entry<String, Integer> entry : numbers.entrySet()) { 10 System.out.print(entry); 11 System.out.print(", "); 12 } 13 } 14 15 //运行结果 16 HashMap: {one=1, two=2, Three=3} 17 one=1, two=2, Three=3,
1. 写操作(Put)
写操作就是在散列表中插入新的键值对(在JDK中叫做Entry)
1) 通过哈希函数来确定Entry的插入位置(index)
2)如果该位置没有元素,就把这个Entry填充进去,但是,由于数组的长度是有限的,当插入的Entry越来越多时,不同的key通过哈希函数获得的下标可能是相同的。这种情况就叫做哈希冲突。
3) 如何解决哈希冲突?
解决哈希冲突的方法主要有两种:开放寻址法和链表法。在Java中,针对解决哈希冲突,ThreadLocal所使用的就是开放寻址法,HashMap中采用的链表法。
开放寻址法:通过探测其他位置来寻找空闲的槽位,直到找到一个可以插入的位置为止。
链表法:HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可,JDK1.7采用头插法,JDK1.8改为采用尾插法。
2. 读操作(Get)
1) 通过哈希函数,把key转化为对应的index
2) 通过index找到对应的元素,由于刚才所说的Hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。
3. 扩容(Resize)
既然散列表是基于数组实现的,那么散列表也要涉及扩容的问题。
当经过多次元素插入,散列表达到一定饱和度时,key映射位置发生冲突的概率会逐渐提高。这样一来,大量元素拥挤在相同的数组下标位置,形成很长的链表,对后续插入操作和查询操作的性能都有很大影响。
这时候,HashMap需要扩展它的长度,也就是进行Resize。
1)影响扩容的两个因素
Capacity:HashMap的当前长度
LoadFactor:HashMap的负载因子,默认值为0.75f
2)衡量HashMap需要进行Resize条件
HashMap.Size >= Capacity * LoadFactor
3)扩容步骤
HashMap的Resize不是简单地把长度扩大,而是经过下面两个步骤:
扩容(Resize):创建一个新的Entry空数组,长度是原数组的2倍。
重新Hash(ReHash):遍历原Entry数组,把所有的Entry重新Hash到新数组中。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。
说明: ReHash在并发的情况下可能会形成链表环,让下一次读操作出现死循环。
结果:经过扩容,原本拥挤的散列表重新变得稀疏,原有的Entry也重新得到了尽可能均匀的分配。
4. HashMap默认的初始长度
1) 初始长度
默认初始长度是16,并且每次自动扩展或者手动初始化时,长度必须是2的幂。
原因:之所以选择16,是为了服务于从Key映射到index的hash算法。长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。
说明:如果长度不是2的幂,那么计算之后,有些index结果的出现几率会更大,而有些index结果永远不会出现。不符合Hash算法均匀分布的原则。
2)哈希函数
从Key映射到HashMap数组的对应位置,会用到一个Hash函数。如何实现一个尽量均匀分布的Hash函数呢?我们通过利用Key的HashCode值来做某种运算。如下:
index = HashCode(Key) & (Length - 1)
说明:取模运算的方式固然简单,但是效率很低。为了实现高效的Hash算法,HashMap的发明者采用了位运算的方式。
5. 高并发下,为什么HashMap可能会出现死循环
Hashmap不是线程安全的。在高并发环境下做插入操作,有可能出现下面的环形链表。
当多个线程同时对HashMap进行插入、删除等操作时,可能会引发链表结构的变化。如果两个线程同时进行插入操作,且发生了哈希冲突,那么它们可能会同时修改链表的结构,例如添加一个新节点到链表头部。在这种情况下,如果另一个线程正在遍历这个链表,就可能导致遍历线程进入死循环,因为链表的结构在遍历过程中发生了变化。
三、HashMap和ConcurrentHashMap的实现有什么不同?
1. HashMap
1)多线程操作导致死循环问题
产生原因:JDK1.7 及之前版本的 HashMap
在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap 。
2)Hash冲突解决方法
JDK1.8 之后在解决哈希冲突时有了较大的变化,当桶中链表长度大于阈值(默认为 8)时
- 如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。这是因为在大多数情况下,扩容可以减少哈希冲突,使链表不会变得过长,从而减少转换为红黑树的必要性。
- 只有当数组已经足够大且链表长度超过 8 时,HashMap 才会进行链表到红黑树的转换。这主要是为了提升查找效率,从原来的 O(n) 降低到 O(log n)。
2. ConcurrentHashMap
1)底层数据结构
JDK1.7 的 ConcurrentHashMap
底层采用 分段的数组 + 链表 实现, JDK 1.8 中的 ConcurrentHashMap 采用了 数组 + 链表/红黑树的结构,类似于 JDK 1.8 中的 HashMap
2)线程安全实现方式
JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock。JDK1.8 放弃了 Segment 分段锁的设计,采用 Node数组 + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表的头节点或红黑二叉树的根节点。
说明:ConcurrentHashMap的详细介绍请参加文章《ConcurrentHashMap的介绍》
3)并发度
JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
参考资料:《程序员小灰》微信公众号